mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-21 20:46:01 +08:00
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:
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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",
|
||||||
|
@ -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
4
uv.lock
generated
@ -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" },
|
||||||
|
Reference in New Issue
Block a user