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](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)

View File

@ -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) {

View File

@ -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,

View File

@ -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

View File

@ -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,11 +612,15 @@ 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:
# If offline, download remote assets and store them in the assets folder
soup = BeautifulSoup(content, "html.parser")
session = requests.Session()
@ -626,13 +631,31 @@ class RevealJS(Converter):
):
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)
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))

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
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")

View File

@ -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) {

View File

@ -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:

View File

@ -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")],