mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-21 04:26:40 +08:00
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:
13
CHANGELOG.md
13
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)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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")
|
||||
|
@ -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) {
|
||||
|
@ -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:
|
||||
|
@ -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")],
|
||||
|
Reference in New Issue
Block a user