feat(convert): allow fully offline HTML presentation (#440)

* feat(cli): allow offline HTML presentations

* feat(convert): allow fully offline HTML presentation

TODO: check if this is really the case, especially for nested dependencies?

Closes #438

* fix(cli): typo

* chore(fmt): auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore(tests): add tests

* fix(cli): use full path

* fix(tests): typo

* chore(ci): avoid specific kernel name

* fix ?

* chore(lib): simplify logic

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jérome Eertmans
2024-11-01 10:00:48 +01:00
committed by GitHub
parent 75af26e601
commit d8acbae165
8 changed files with 106 additions and 45 deletions

View File

@ -6,9 +6,6 @@ build:
apt_packages: apt_packages:
- libpango1.0-dev - libpango1.0-dev
- ffmpeg - ffmpeg
jobs:
post_install:
- ipython kernel install --name "manim-slides" --user
sphinx: sphinx:
builder: html builder: html
configuration: docs/source/conf.py configuration: docs/source/conf.py

View File

@ -10,6 +10,13 @@ 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.1.9...HEAD) ## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.1.9...HEAD)
(unreleased-added)=
### Added
- Added `--offline` option to `manim-slides convert` for offline
HTML presentations.
[#440](https://github.com/jeertmans/manim-slides/pull/440)
(v5.1.9)= (v5.1.9)=
## [v5.1.9](https://github.com/jeertmans/manim-slides/compare/v5.1.8...v5.1.9) ## [v5.1.9](https://github.com/jeertmans/manim-slides/compare/v5.1.8...v5.1.9)

View File

@ -78,9 +78,9 @@
], ],
"metadata": { "metadata": {
"kernelspec": { "kernelspec": {
"display_name": "manim-slides", "display_name": ".venv",
"language": "python", "language": "python",
"name": "manim-slides" "name": "python3"
}, },
"language_info": { "language_info": {
"codemirror_mode": { "codemirror_mode": {
@ -92,7 +92,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.10.6" "version": "3.11.8"
} }
}, },
"nbformat": 4, "nbformat": 4,

View File

@ -15,6 +15,8 @@ from typing import Any, Callable, Optional, Union
import av import av
import click import click
import pptx import pptx
import requests
from bs4 import BeautifulSoup
from click import Context, Parameter from click import Context, Parameter
from jinja2 import Template from jinja2 import Template
from lxml import etree from lxml import etree
@ -287,8 +289,11 @@ class RevealTheme(str, StrEnum):
class RevealJS(Converter): class RevealJS(Converter):
# Export option: use data-uri # Export option:
data_uri: bool = False data_uri: bool = False
offline: bool = Field(
False, description="Download remote assets for offline presentation."
)
# Presentation size options from RevealJS # Presentation size options from RevealJS
width: Union[Str, int] = Str("100%") width: Union[Str, int] = Str("100%")
height: Union[Str, int] = Str("100%") height: Union[Str, int] = Str("100%")
@ -385,27 +390,25 @@ class RevealJS(Converter):
def open(self, file: Path) -> None: def open(self, file: Path) -> None:
webbrowser.open(file.absolute().as_uri()) webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None: # noqa: C901
""" """
Convert this configuration into a RevealJS HTML presentation, saved to Convert this configuration into a RevealJS HTML presentation, saved to
DEST. DEST.
""" """
if self.data_uri: dirname = dest.parent
assets_dir = Path("") # Actually we won't care. basename = dest.stem
else: ext = dest.suffix
dirname = dest.parent
basename = dest.stem
ext = dest.suffix
assets_dir = Path( assets_dir = Path(
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext) self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
) )
full_assets_dir = dirname / assets_dir full_assets_dir = dirname / assets_dir
if not self.data_uri 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) full_assets_dir.mkdir(parents=True, exist_ok=True)
if not self.data_uri:
num_presentation_configs = len(self.presentation_configs) num_presentation_configs = len(self.presentation_configs)
if num_presentation_configs > 1: if num_presentation_configs > 1:
@ -435,7 +438,9 @@ class RevealJS(Converter):
revealjs_template = Template(self.load_template()) revealjs_template = Template(self.load_template())
options = self.model_dump() options = self.model_dump()
options["assets_dir"] = assets_dir
if assets_dir is not None:
options["assets_dir"] = assets_dir
has_notes = any( has_notes = any(
slide_config.notes != "" slide_config.notes != ""
@ -451,6 +456,24 @@ class RevealJS(Converter):
**options, **options,
) )
if self.offline:
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)
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) f.write(content)
@ -590,7 +613,7 @@ class PowerPoint(Converter):
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to add a `--show-config` option.""" """Wrap a function to add a '--show-config' option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None: def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
@ -621,7 +644,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]: def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to add a `--show-template` option.""" """Wrap a function to add a '--show-template' option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None: def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
@ -666,7 +689,6 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
is_flag=True, is_flag=True,
help="Open the newly created file using the appropriate application.", help="Open the newly created file using the appropriate application.",
) )
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
@click.option( @click.option(
"-c", "-c",
"--config", "--config",
@ -674,7 +696,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
multiple=True, multiple=True,
callback=validate_config_option, callback=validate_config_option,
help="Configuration options passed to the converter. " help="Configuration options passed to the converter. "
"E.g., pass ``-cslide_number=true`` to display slide numbers.", "E.g., pass '-cslide_number=true' to display slide numbers.",
) )
@click.option( @click.option(
"--use-template", "--use-template",
@ -682,7 +704,13 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
metavar="FILE", metavar="FILE",
type=click.Path(exists=True, dir_okay=False, path_type=Path), type=click.Path(exists=True, dir_okay=False, path_type=Path),
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(
"--offline",
is_flag=True,
help="Download any remote content and store it in the assets folder. "
"The is a convenient alias to '-coffline=true'.",
) )
@show_template_option @show_template_option
@show_config_options @show_config_options
@ -693,9 +721,9 @@ def convert(
dest: Path, dest: Path,
to: str, to: str,
open_result: bool, open_result: bool,
force: bool,
config_options: dict[str, str], config_options: dict[str, str],
template: Optional[Path], template: Optional[Path],
offline: 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)
@ -713,6 +741,13 @@ def convert(
else: else:
cls = Converter.from_string(to) cls = Converter.from_string(to)
if (
offline
and issubclass(cls, (RevealJS, HtmlZip))
and "offline" not in config_options
):
config_options["offline"] = "true"
converter = cls( converter = cls(
presentation_configs=presentation_configs, presentation_configs=presentation_configs,
template=template, template=template,

View File

@ -19,7 +19,7 @@
<body> <body>
<div class="reveal"> <div class="reveal">
<div class="slides"> <div class="slides">
{%- 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 data_uri -%}
@ -27,23 +27,24 @@
{%- else -%} {%- else -%}
{% set file = assets_dir / slide_config.file.name %} {% set file = assets_dir / slide_config.file.name %}
{%- endif -%} {%- endif -%}
<section <section
data-background-size={{ background_size }} data-background-size={{ background_size }}
data-background-color="{{ presentation_config.background_color }}" data-background-color="{{ presentation_config.background_color }}"
data-background-video="{{ file }}" data-background-video="{{ file }}"
{% if loop.index == 1 and outer_loop.index == 1 -%} {% if loop.index == 1 and outer_loop.index == 1 -%}
data-background-video-muted data-background-video-muted
{%- endif %} {%- endif -%}
{% if slide_config.loop -%} {% if slide_config.loop -%}
data-background-video-loop data-background-video-loop
{%- endif -%} {%- endif -%}
{% if slide_config.auto_next -%} {% if slide_config.auto_next -%}
data-autoslide="{{ get_duration_ms(slide_config.file) }}" data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{%- endif -%}> {%- endif %}
{% if slide_config.notes != "" -%} >
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside> {%- if slide_config.notes != "" -%}
{%- endif %} <aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
</section> {%- endif %}
</section>
{%- endfor -%} {%- endfor -%}
{%- endfor -%} {%- endfor -%}
</div> </div>

View File

@ -18,6 +18,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"av>=9.0.0", "av>=9.0.0",
"beautifulsoup4>=4.12.3",
"click>=8.1.3", "click>=8.1.3",
"click-default-group>=1.2.2", "click-default-group>=1.2.2",
"jinja2>=3.1.2", "jinja2>=3.1.2",

View File

@ -156,6 +156,24 @@ class TestConverter:
file_contents = out_file.read_text() file_contents = out_file.read_text()
assert "manim" in file_contents.casefold() assert "manim" in file_contents.casefold()
def test_revealjs_offline_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.html"
RevealJS(presentation_configs=[presentation_config], offline="true").convert_to(
out_file
)
assert out_file.exists()
assets_dir = Path(tmp_path / "slides_assets")
assert assets_dir.is_dir()
for file in [
"black.min.css",
"reveal.min.css",
"reveal.min.js",
"zenburn.min.css",
]:
assert (assets_dir / file).exists()
def test_htmlzip_converter( def test_htmlzip_converter(
self, tmp_path: Path, presentation_config: PresentationConfig self, tmp_path: Path, presentation_config: PresentationConfig
) -> None: ) -> None:

4
uv.lock generated
View File

@ -1237,10 +1237,11 @@ wheels = [
[[package]] [[package]]
name = "manim-slides" name = "manim-slides"
version = "5.1.7" version = "5.1.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "av" }, { name = "av" },
{ name = "beautifulsoup4" },
{ name = "click" }, { name = "click" },
{ name = "click-default-group" }, { name = "click-default-group" },
{ name = "jinja2" }, { name = "jinja2" },
@ -1335,6 +1336,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "av", specifier = ">=9.0.0" }, { name = "av", specifier = ">=9.0.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.3" },
{ name = "click", specifier = ">=8.1.3" }, { name = "click", specifier = ">=8.1.3" },
{ name = "click-default-group", specifier = ">=1.2.2" }, { name = "click-default-group", specifier = ">=1.2.2" },
{ name = "docutils", marker = "extra == 'docs'", specifier = ">=0.20.1" }, { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.20.1" },