feat(convert/html): inline CSS and JS with convert --one_file --offline (#505)

* 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 <jeertmans@icloud.com>

* 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 <jeertmans@icloud.com>

* Update manim_slides/convert.py

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* Update docs/source/reference/sharing.md

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* Update manim_slides/convert.py

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* 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 <jeertmans@icloud.com>
This commit is contained in:
Rodrigo Martín
2025-01-03 13:49:05 +01:00
committed by GitHub
parent e50271b0b2
commit 1189f37cf3
9 changed files with 238 additions and 34 deletions

View File

@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(unreleased)= (unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.2.0...HEAD) ## [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)=
## [v5.2.0](https://github.com/jeertmans/manim-slides/compare/v5.1.10...v5.2.0) ## [v5.2.0](https://github.com/jeertmans/manim-slides/compare/v5.1.10...v5.2.0)

View File

@ -21,7 +21,7 @@
{%- for presentation_config in presentation_configs -%} {%- for presentation_config in presentation_configs -%}
{% set outer_loop = loop %} {% set outer_loop = loop %}
{%- for slide_config in presentation_config.slides -%} {%- for slide_config in presentation_config.slides -%}
{%- if data_uri -%} {%- if one_file -%}
{% set file = file_to_data_uri(slide_config.file) %} {% set file = file_to_data_uri(slide_config.file) %}
{%- else -%} {%- else -%}
{% set file = assets_dir / slide_config.file.name %} {% set file = assets_dir / slide_config.file.name %}
@ -315,7 +315,7 @@
hideCursorTime: {{ hide_cursor_time }} hideCursorTime: {{ hide_cursor_time }}
}); });
{% if data_uri %} {% if one_file %}
// Fix found by @t-fritsch on GitHub // Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475. // see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) { function fixBase64VideoBackground(event) {

View File

@ -102,7 +102,7 @@ Questions related to `manim-slides convert [SCENES]... output.html`.
### I moved my `.html` file and it stopped working ### 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 then Manim Slides generated a folder containing all
the video files, in the same folder as the HTML the video files, in the same folder as the HTML
output. As the path to video files is a relative path, output. As the path to video files is a relative path,

View File

@ -137,9 +137,10 @@ and it there to preserve the original aspect ratio (16:9).
### Sharing ONE HTML file ### Sharing ONE HTML file
If you set the `data_uri` option to `true` (with `-cdata_uri=true`), If you set the `--one-file` flag, all animations will be data URI encoded,
all animations will be data URI encoded, making the HTML a self-contained making the HTML a self-contained presentation file that can be shared
presentation file that can be shared on its own. 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 ### Over the internet

View File

@ -5,6 +5,7 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import textwrap import textwrap
import warnings
import webbrowser import webbrowser
from base64 import b64encode from base64 import b64encode
from collections import deque from collections import deque
@ -298,9 +299,9 @@ class RevealJS(Converter):
Please check out https://revealjs.com/config/ for more details. Please check out https://revealjs.com/config/ for more details.
""" """
# Export option: use data-uri # Export option:
data_uri: bool = Field( one_file: bool = Field(
False, description="Store all animations inside the HTML as data URI." False, description="Embed all assets (e.g., animations) inside the HTML."
) )
offline: bool = Field( offline: bool = Field(
False, description="Download remote assets for offline presentation." False, description="Download remote assets for offline presentation."
@ -562,11 +563,10 @@ class RevealJS(Converter):
) )
full_assets_dir = dirname / assets_dir 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}") 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) num_presentation_configs = len(self.presentation_configs)
if num_presentation_configs > 1: if num_presentation_configs > 1:
@ -585,6 +585,7 @@ class RevealJS(Converter):
def prefix(i: int) -> str: def prefix(i: int) -> str:
return "" return ""
full_assets_dir.mkdir(parents=True, exist_ok=True)
for i, presentation_config in enumerate(self.presentation_configs): for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.copy_to( presentation_config.copy_to(
full_assets_dir, include_reversed=False, prefix=prefix(i) full_assets_dir, include_reversed=False, prefix=prefix(i)
@ -611,11 +612,15 @@ class RevealJS(Converter):
get_duration_ms=get_duration_ms, get_duration_ms=get_duration_ms,
has_notes=has_notes, has_notes=has_notes,
env=os.environ, env=os.environ,
prefix=prefix if not self.data_uri else None, prefix=prefix if not self.one_file else None,
**options, **options,
) )
# If not offline, write the content to the file
if not self.offline:
f.write(content)
return
if self.offline: # If offline, download remote assets and store them in the assets folder
soup = BeautifulSoup(content, "html.parser") soup = BeautifulSoup(content, "html.parser")
session = requests.Session() session = requests.Session()
@ -626,13 +631,31 @@ class RevealJS(Converter):
): ):
asset_name = link.rsplit("/", 1)[1] asset_name = link.rsplit("/", 1)[1]
asset = session.get(link) 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: with open(full_assets_dir / asset_name, "wb") as asset_file:
asset_file.write(asset.content) asset_file.write(asset.content)
item[inner] = str(assets_dir / asset_name) item[inner] = str(assets_dir / asset_name)
content = str(soup) content = str(soup)
f.write(content) 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. " help="Use the template given by FILE instead of default one. "
"To echo the default template, use '--show-template'.", "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( @click.option(
"--offline", "--offline",
is_flag=True, is_flag=True,
@ -937,6 +966,7 @@ def convert(
config_options: dict[str, str], config_options: dict[str, str],
template: Optional[Path], template: Optional[Path],
offline: bool, offline: bool,
one_file: bool,
) -> None: ) -> None:
"""Convert SCENE(s) into a given format and writes the result in DEST.""" """Convert SCENE(s) into a given format and writes the result in DEST."""
presentation_configs = get_scenes_presentation_config(scenes, folder) presentation_configs = get_scenes_presentation_config(scenes, folder)
@ -954,6 +984,28 @@ def convert(
else: else:
cls = Converter.from_string(to) 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 ( if (
offline offline
and issubclass(cls, (RevealJS, HtmlZip)) and issubclass(cls, (RevealJS, HtmlZip))

View File

@ -125,7 +125,7 @@ class ManimSlidesMagic(Magics): # type: ignore
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
could look as follows:: 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): class MySlide(Slide):
def construct(self): def construct(self):
@ -222,17 +222,29 @@ class ManimSlidesMagic(Magics): # type: ignore
kwargs = dict(arg.split("=", 1) for arg in manim_slides_args) kwargs = dict(arg.split("=", 1) for arg in manim_slides_args)
if embed: # Embedding implies data-uri # If data_uri is set, raise a warning
kwargs["data_uri"] = "true" 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 # TODO: FIXME
# Seems like files are blocked so date-uri is the only working option... # Seems like files are blocked so one_file is the only working option...
if kwargs.get("data_uri", "false").lower().strip() == "false": if kwargs.get("one_file", "false").lower().strip() == "false":
logger.warning( 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." "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( presentation_configs = get_scenes_presentation_config(
[clsname], Path("./slides") [clsname], Path("./slides")

View File

@ -22,7 +22,7 @@
{% for presentation_config in presentation_configs -%} {% for presentation_config in presentation_configs -%}
{% set outer_loop = loop %} {% set outer_loop = loop %}
{%- for slide_config in presentation_config.slides -%} {%- for slide_config in presentation_config.slides -%}
{%- if data_uri -%} {%- if one_file -%}
{% set file = file_to_data_uri(slide_config.file) %} {% set file = file_to_data_uri(slide_config.file) %}
{%- else -%} {%- else -%}
{% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %} {% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %}
@ -320,7 +320,7 @@
hideCursorTime: {{ hide_cursor_time }} hideCursorTime: {{ hide_cursor_time }}
}); });
{% if data_uri -%} {% if one_file -%}
// Fix found by @t-fritsch on GitHub // Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475. // see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) { function fixBase64VideoBackground(event) {

View File

@ -3,6 +3,8 @@ from enum import EnumMeta
from pathlib import Path from pathlib import Path
import pytest import pytest
import requests
from bs4 import BeautifulSoup
from manim_slides.config import PresentationConfig from manim_slides.config import PresentationConfig
from manim_slides.convert import ( from manim_slides.convert import (
@ -173,6 +175,101 @@ class TestConverter:
]: ]:
assert (assets_dir / file).exists() 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( def test_htmlzip_converter(
self, tmp_path: Path, presentation_config: PresentationConfig self, tmp_path: Path, presentation_config: PresentationConfig
) -> None: ) -> None:

View File

@ -1,3 +1,4 @@
import warnings
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -64,6 +65,34 @@ def test_convert(slides_folder: Path, extension: str) -> None:
assert results.exit_code == 0 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( @pytest.mark.parametrize(
("extension", "expected_log"), ("extension", "expected_log"),
[("html", ""), ("pdf", ""), ("pptx", ""), ("ppt", "WARNING")], [("html", ""), ("pdf", ""), ("pptx", ""), ("ppt", "WARNING")],