From 1189f37cf3db40370806b83f2edbb476c3aa903f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Fri, 3 Jan 2025 13:49:05 +0100 Subject: [PATCH] feat(convert/html): inline CSS and JS with convert --one_file --offline (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Inline CSS and JS by default with --offline * chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore: Add test * Add one_file parameter * chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix lint * Fix typo * Fix typo * Fix IPython magic doc * Update manim_slides/convert.py Co-authored-by: Jérome Eertmans * Add test for one_file=true * chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update manim_slides/convert.py Co-authored-by: Jérome Eertmans * Update manim_slides/convert.py Co-authored-by: Jérome Eertmans * Update docs/source/reference/sharing.md Co-authored-by: Jérome Eertmans * Update manim_slides/convert.py Co-authored-by: Jérome Eertmans * chore(fmt): auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add changelog and tests * Fix IPython magic * Update docs/source/faq.md * Update CHANGELOG.md --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jérome Eertmans --- CHANGELOG.md | 13 ++++ docs/source/_static/template.html | 4 +- docs/source/faq.md | 2 +- docs/source/reference/sharing.md | 7 +- manim_slides/convert.py | 90 +++++++++++++++++++------ manim_slides/ipython/ipython_magic.py | 26 +++++-- manim_slides/templates/revealjs.html | 4 +- tests/test_convert.py | 97 +++++++++++++++++++++++++++ tests/test_main.py | 29 ++++++++ 9 files changed, 238 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3941369..3cc4c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (unreleased)= ## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.2.0...HEAD) +(unreleased-added)= +### Added + +- Added CSS and JS inline for `manim-slides convert` if `--offline` + and `--one-file` (`-cone_file`) are used for HTML output. + [@Rapsssito](https://github.com/Rapsssito) [#505](https://github.com/jeertmans/manim-slides/pull/505) + +(unreleased-changed)= +### Changed + +- Deprecate `-cdata_uri` in favor of `-cone_file` for `manim-slides convert`. + [@Rapsssito](https://github.com/Rapsssito) [#505](https://github.com/jeertmans/manim-slides/pull/505) + (v5.2.0)= ## [v5.2.0](https://github.com/jeertmans/manim-slides/compare/v5.1.10...v5.2.0) diff --git a/docs/source/_static/template.html b/docs/source/_static/template.html index a3008d2..f13c3ef 100644 --- a/docs/source/_static/template.html +++ b/docs/source/_static/template.html @@ -21,7 +21,7 @@ {%- for presentation_config in presentation_configs -%} {% set outer_loop = loop %} {%- for slide_config in presentation_config.slides -%} - {%- if data_uri -%} + {%- if one_file -%} {% set file = file_to_data_uri(slide_config.file) %} {%- else -%} {% set file = assets_dir / slide_config.file.name %} @@ -315,7 +315,7 @@ hideCursorTime: {{ hide_cursor_time }} }); - {% if data_uri %} + {% if one_file %} // Fix found by @t-fritsch on GitHub // see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475. function fixBase64VideoBackground(event) { diff --git a/docs/source/faq.md b/docs/source/faq.md index 1a43608..8717304 100644 --- a/docs/source/faq.md +++ b/docs/source/faq.md @@ -102,7 +102,7 @@ Questions related to `manim-slides convert [SCENES]... output.html`. ### I moved my `.html` file and it stopped working -If you did not specify `-cdata_uri=true` when converting, +If you did not specify `--one-file` (or `-cone_file=true`) when converting, then Manim Slides generated a folder containing all the video files, in the same folder as the HTML output. As the path to video files is a relative path, diff --git a/docs/source/reference/sharing.md b/docs/source/reference/sharing.md index b21c51a..9b5e093 100644 --- a/docs/source/reference/sharing.md +++ b/docs/source/reference/sharing.md @@ -137,9 +137,10 @@ and it there to preserve the original aspect ratio (16:9). ### Sharing ONE HTML file -If you set the `data_uri` option to `true` (with `-cdata_uri=true`), -all animations will be data URI encoded, making the HTML a self-contained -presentation file that can be shared on its own. +If you set the `--one-file` flag, all animations will be data URI encoded, +making the HTML a self-contained presentation file that can be shared +on its own. If you also set the `--offline` flag, the JS and CSS files will +be included in the HTML file as well. ### Over the internet diff --git a/manim_slides/convert.py b/manim_slides/convert.py index 5acd1ac..cf99d57 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -5,6 +5,7 @@ import shutil import subprocess import tempfile import textwrap +import warnings import webbrowser from base64 import b64encode from collections import deque @@ -298,9 +299,9 @@ class RevealJS(Converter): Please check out https://revealjs.com/config/ for more details. """ - # Export option: use data-uri - data_uri: bool = Field( - False, description="Store all animations inside the HTML as data URI." + # Export option: + one_file: bool = Field( + False, description="Embed all assets (e.g., animations) inside the HTML." ) offline: bool = Field( False, description="Download remote assets for offline presentation." @@ -562,11 +563,10 @@ class RevealJS(Converter): ) full_assets_dir = dirname / assets_dir - if not self.data_uri or self.offline: + if not self.one_file or self.offline: logger.debug(f"Assets will be saved to: {full_assets_dir}") - full_assets_dir.mkdir(parents=True, exist_ok=True) - if not self.data_uri: + if not self.one_file: num_presentation_configs = len(self.presentation_configs) if num_presentation_configs > 1: @@ -585,6 +585,7 @@ class RevealJS(Converter): def prefix(i: int) -> str: return "" + full_assets_dir.mkdir(parents=True, exist_ok=True) for i, presentation_config in enumerate(self.presentation_configs): presentation_config.copy_to( full_assets_dir, include_reversed=False, prefix=prefix(i) @@ -611,28 +612,50 @@ class RevealJS(Converter): get_duration_ms=get_duration_ms, has_notes=has_notes, env=os.environ, - prefix=prefix if not self.data_uri else None, + prefix=prefix if not self.one_file else None, **options, ) + # If not offline, write the content to the file + if not self.offline: + f.write(content) + return - if self.offline: - soup = BeautifulSoup(content, "html.parser") - session = requests.Session() + # If offline, download remote assets and store them in the assets folder + soup = BeautifulSoup(content, "html.parser") + session = requests.Session() - for tag, inner in [("link", "href"), ("script", "src")]: - for item in soup.find_all(tag): - if item.has_attr(inner) and (link := item[inner]).startswith( - "http" - ): - asset_name = link.rsplit("/", 1)[1] - asset = session.get(link) + for tag, inner in [("link", "href"), ("script", "src")]: + for item in soup.find_all(tag): + if item.has_attr(inner) and (link := item[inner]).startswith( + "http" + ): + asset_name = link.rsplit("/", 1)[1] + asset = session.get(link) + if self.one_file: + # If it is a CSS file, inline it + if tag == "link" and "stylesheet" in item["rel"]: + item.decompose() + style = soup.new_tag("style") + style.string = asset.text + soup.head.append(style) + # If it is a JS file, inline it + elif tag == "script": + item.decompose() + script = soup.new_tag("script") + script.string = asset.text + soup.head.append(script) + else: + raise ValueError( + f"Unable to inline {tag} asset: {link}" + ) + else: + full_assets_dir.mkdir(parents=True, exist_ok=True) with open(full_assets_dir / asset_name, "wb") as asset_file: asset_file.write(asset.content) item[inner] = str(assets_dir / asset_name) - content = str(soup) - + content = str(soup) f.write(content) @@ -919,6 +942,12 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]: help="Use the template given by FILE instead of default one. " "To echo the default template, use '--show-template'.", ) +@click.option( + "--one-file", + is_flag=True, + help="Embed all local assets (e.g., video files) in the output file. " + "The is a convenient alias to '-cone_file=true'.", +) @click.option( "--offline", is_flag=True, @@ -937,6 +966,7 @@ def convert( config_options: dict[str, str], template: Optional[Path], offline: bool, + one_file: bool, ) -> None: """Convert SCENE(s) into a given format and writes the result in DEST.""" presentation_configs = get_scenes_presentation_config(scenes, folder) @@ -954,6 +984,28 @@ def convert( else: cls = Converter.from_string(to) + if ( + one_file + and issubclass(cls, (RevealJS, HtmlZip)) + and "one_file" not in config_options + ): + config_options["one_file"] = "true" + + # Change data_uri to one_file and print a warning if present + if "data_uri" in config_options: + warnings.warn( + "The 'data_uri' configuration option is deprecated and will be " + "removed in the next major version. " + "Use 'one_file' instead.", + DeprecationWarning, + stacklevel=2, + ) + config_options["one_file"] = ( + config_options["one_file"] + if "one_file" in config_options + else config_options.pop("data_uri") + ) + if ( offline and issubclass(cls, (RevealJS, HtmlZip)) diff --git a/manim_slides/ipython/ipython_magic.py b/manim_slides/ipython/ipython_magic.py index bcf6c4d..62ca96e 100644 --- a/manim_slides/ipython/ipython_magic.py +++ b/manim_slides/ipython/ipython_magic.py @@ -125,7 +125,7 @@ class ManimSlidesMagic(Magics): # type: ignore in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides could look as follows:: - %%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true data_uri=true + %%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true one_file=true class MySlide(Slide): def construct(self): @@ -222,17 +222,29 @@ class ManimSlidesMagic(Magics): # type: ignore kwargs = dict(arg.split("=", 1) for arg in manim_slides_args) - if embed: # Embedding implies data-uri - kwargs["data_uri"] = "true" + # If data_uri is set, raise a warning + if "data_uri" in kwargs: + logger.warning( + "'data_uri' configuration option is deprecated and will be removed in a future release. " + "Please use 'one_file' instead." + ) + kwargs["one_file"] = ( + kwargs["one_file"] + if "one_file" in kwargs + else kwargs.pop("data_uri") + ) + + if embed: # Embedding implies one_file + kwargs["one_file"] = "true" # TODO: FIXME - # Seems like files are blocked so date-uri is the only working option... - if kwargs.get("data_uri", "false").lower().strip() == "false": + # Seems like files are blocked so one_file is the only working option... + if kwargs.get("one_file", "false").lower().strip() == "false": logger.warning( - "data_uri option is currently automatically enabled, " + "one_file option is currently automatically enabled, " "because using local video files does not seem to work properly." ) - kwargs["data_uri"] = "true" + kwargs["one_file"] = "true" presentation_configs = get_scenes_presentation_config( [clsname], Path("./slides") diff --git a/manim_slides/templates/revealjs.html b/manim_slides/templates/revealjs.html index 4b0ce8e..d87654c 100644 --- a/manim_slides/templates/revealjs.html +++ b/manim_slides/templates/revealjs.html @@ -22,7 +22,7 @@ {% for presentation_config in presentation_configs -%} {% set outer_loop = loop %} {%- for slide_config in presentation_config.slides -%} - {%- if data_uri -%} + {%- if one_file -%} {% set file = file_to_data_uri(slide_config.file) %} {%- else -%} {% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %} @@ -320,7 +320,7 @@ hideCursorTime: {{ hide_cursor_time }} }); - {% if data_uri -%} + {% if one_file -%} // Fix found by @t-fritsch on GitHub // see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475. function fixBase64VideoBackground(event) { diff --git a/tests/test_convert.py b/tests/test_convert.py index 7466177..ef07cfd 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -3,6 +3,8 @@ from enum import EnumMeta from pathlib import Path import pytest +import requests +from bs4 import BeautifulSoup from manim_slides.config import PresentationConfig from manim_slides.convert import ( @@ -173,6 +175,101 @@ class TestConverter: ]: assert (assets_dir / file).exists() + def test_revealjs_data_encode( + self, + tmp_path: Path, + presentation_config: PresentationConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # Mock requests.Session.get to return a fake response (should not be called) + class MockResponse: + def __init__(self, content: bytes, text: str, status_code: int) -> None: + self.content = content + self.text = text + self.status_code = status_code + + # Apply the monkeypatch + monkeypatch.setattr( + requests.Session, + "get", + lambda self, url: MockResponse( + b"body { background-color: #9a3241; }", + "body { background-color: #9a3241; }", + 200, + ), + ) + out_file = tmp_path / "slides.html" + RevealJS( + presentation_configs=[presentation_config], offline="false", one_file="true" + ).convert_to(out_file) + assert out_file.exists() + # Check that assets are not stored + assert not (tmp_path / "slides_assets").exists() + + with open(out_file, encoding="utf-8") as file: + content = file.read() + + soup = BeautifulSoup(content, "html.parser") + + # Check if video is encoded in base64 + videos = soup.find_all("section") + assert all( + "data:video/mp4;base64," in video["data-background-video"] + for video in videos + ) + + # Check if CSS is not inlined + styles = soup.find_all("style") + assert not any("background-color: #9a3241;" in style.string for style in styles) + # Check if JS is not inlined + scripts = soup.find_all("script") + assert not any( + "background-color: #9a3241;" in (script.string or "") for script in scripts + ) + + def test_revealjs_offline_inlining( + self, + tmp_path: Path, + presentation_config: PresentationConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # Mock requests.Session.get to return a fake response + class MockResponse: + def __init__(self, content: bytes, text: str, status_code: int) -> None: + self.content = content + self.text = text + self.status_code = status_code + + # Apply the monkeypatch + monkeypatch.setattr( + requests.Session, + "get", + lambda self, url: MockResponse( + b"body { background-color: #9a3241; }", + "body { background-color: #9a3241; }", + 200, + ), + ) + + out_file = tmp_path / "slides.html" + RevealJS( + presentation_configs=[presentation_config], offline="true", one_file="true" + ).convert_to(out_file) + assert out_file.exists() + + with open(out_file, encoding="utf-8") as file: + content = file.read() + + soup = BeautifulSoup(content, "html.parser") + + # Check if CSS is inlined + styles = soup.find_all("style") + assert any("background-color: #9a3241;" in style.string for style in styles) + + # Check if JS is inlined + scripts = soup.find_all("script") + assert any("background-color: #9a3241;" in script.string for script in scripts) + def test_htmlzip_converter( self, tmp_path: Path, presentation_config: PresentationConfig ) -> None: diff --git a/tests/test_main.py b/tests/test_main.py index fc03c52..40081ee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import warnings from pathlib import Path import pytest @@ -64,6 +65,34 @@ def test_convert(slides_folder: Path, extension: str) -> None: assert results.exit_code == 0 +@pytest.mark.parametrize(("extension",), [("html",)]) +def test_convert_data_uri_deprecated(slides_folder: Path, extension: str) -> None: + runner = CliRunner(mix_stderr=False) + + with runner.isolated_filesystem(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + results = runner.invoke( + cli, + [ + "convert", + "BasicSlide", + f"basic_example.{extension}", + "--folder", + str(slides_folder), + "--to", + extension, + "-cdata_uri=true", + ], + ) + assert any( + "'data_uri' configuration option is deprecated" in str(item.message) + and item.category is DeprecationWarning + for item in w + ) + assert results.exit_code == 0 + + @pytest.mark.parametrize( ("extension", "expected_log"), [("html", ""), ("pdf", ""), ("pptx", ""), ("ppt", "WARNING")],