From c0c73ad4d4771bd3593ca503cca21aedab31f7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Wed, 7 Dec 2022 16:27:23 +0100 Subject: [PATCH] feat(cli): feally featured RevealJS template (#80) * feat(cli): feally featured RevealJS template This adds an option for every possible RevealJS option. Error messages are also improved. RevealJS version is bumped to latest (4.4.0). * feat(cli): add primitive support for arbitrary JS functions * fix(cli): some typos / issues in template * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(lib): pickling issue * fix(lib): ignore typing error due to __reduce_ex__ * feat(cli): add template selection, fixes first slide bug, and rm stuff Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/pages.yml | 2 +- .gitignore | 4 + manim_slides/convert.py | 319 +++++++++++++++++-- manim_slides/data/revealjs_template.html | 388 +++++++++++++++-------- 4 files changed, 537 insertions(+), 176 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 043fdd4..79520eb 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -48,7 +48,7 @@ jobs: - name: Build animation and convert it into HTML slides run: | poetry run manim example.py ConvertExample - poetry run manim-slides convert ConvertExample docs/source/_static/slides.html -cembedded=true -ccontrols=true + poetry run manim-slides convert ConvertExample docs/source/_static/slides.html -ccontrols=true - name: Build docs run: cd docs && make html - name: Upload artifact diff --git a/.gitignore b/.gitignore index 10c8865..dbb5eba 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ docs/build/ docs/source/_static/slides_assets/ docs/source/_static/slides.html + +slides_assets/ + +slides.html diff --git a/manim_slides/convert.py b/manim_slides/convert.py index 0b1ca54..3257833 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -1,12 +1,12 @@ import os import webbrowser from enum import Enum -from typing import Any, Callable, Dict, Generator, List, Type +from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union import click import pkg_resources from click import Context, Parameter -from pydantic import BaseModel +from pydantic import BaseModel, PositiveInt, ValidationError from .commons import folder_path_option, verbosity_option from .config import PresentationConfig @@ -34,14 +34,21 @@ def validate_config_option( class Converter(BaseModel): # type: ignore presentation_configs: List[PresentationConfig] = [] assets_dir: str = "{basename}_assets" + template: Optional[str] = None def convert_to(self, dest: str) -> None: """Converts self, i.e., a list of presentations, into a given format.""" raise NotImplementedError + def load_template(self) -> str: + """Returns the template as a string. + + An empty string is returned if no template is used.""" + return "" + def open(self, file: str) -> bool: """Opens a file, generated with converter, using appropriate application.""" - return webbrowser.open(file) + raise NotImplementedError @classmethod def from_string(cls, s: str) -> Type["Converter"]: @@ -51,11 +58,126 @@ class Converter(BaseModel): # type: ignore }[s] -class JSBool(str, Enum): +class Str(str): + """A simple string, but quoted when needed.""" + + # This fixes pickling issue on Python 3.8 + __reduce_ex__ = str.__reduce_ex__ + + def __str__(self) -> str: + """Ensures that the string is correctly quoted.""" + if self in ["true", "false", "null"]: + return super().__str__() + else: + return f"'{super().__str__()}'" + + +Function = str # Basically, anything + + +class JsTrue(str, Enum): + true = "true" + + +class JsFalse(str, Enum): + false = "false" + + +class JsBool(Str, Enum): # type: ignore true = "true" false = "false" +class JsNull(Str, Enum): # type: ignore + null = "null" + + +class ControlsLayout(Str, Enum): # type: ignore + edges = "edges" + bottom_right = "bottom-right" + + +class ControlsBackArrows(Str, Enum): # type: ignore + faded = "faded" + hidden = "hidden" + visibly = "visibly" + + +class SlideNumber(Str, Enum): # type: ignore + true = "true" + false = "false" + hdotv = "h.v" + handv = "h/v" + c = "c" + candt = "c/t" + + +class ShowSlideNumber(Str, Enum): # type: ignore + all = "all" + print = "print" + speaker = "speaker" + + +class KeyboardCondition(Str, Enum): # type: ignore + null = "null" + focused = "focused" + + +class NavigationMode(Str, Enum): # type: ignore + default = "default" + linear = "linear" + grid = "grid" + + +class AutoPlayMedia(Str, Enum): # type: ignore + null = "null" + true = "true" + false = "false" + + +PreloadIframes = AutoPlayMedia + + +class AutoAnimateMatcher(Str, Enum): # type: ignore + null = "null" + + +class AutoAnimateEasing(Str, Enum): # type: ignore + ease = "ease" + + +AutoSlide = Union[PositiveInt, JsFalse] + + +class AutoSlideMethod(Str, Enum): # type: ignore + null = "null" + + +MouseWheel = Union[JsNull, float] + + +class Transition(Str, Enum): # type: ignore + none = "none" + fade = "fade" + slide = "slide" + convex = "convex" + concave = "concave" + zoom = "zoom" + + +class TransitionSpeed(Str, Enum): # type: ignore + default = "default" + fast = "fast" + slow = "slow" + + +BackgroundTransition = Transition + + +class Display(Str, Enum): # type: ignore + block = "block" + + class RevealTheme(str, Enum): black = "black" white = "white" @@ -71,21 +193,90 @@ class RevealTheme(str, Enum): class RevealJS(Converter): - background_color: str = "black" - controls: JSBool = JSBool.false - embedded: JSBool = JSBool.false - fragments: JSBool = JSBool.false - height: str = "100%" - loop: JSBool = JSBool.false - progress: JSBool = JSBool.false - reveal_version: str = "3.7.0" + # Presentation size options from RevealJS + width: Union[Str, int] = Str("100%") + height: Union[Str, int] = Str("100%") + margin: float = 0.04 + min_scale: float = 0.2 + max_scale: float = 2.0 + # Configuration options from RevealJS + controls: JsBool = JsBool.false + controls_tutorial: JsBool = JsBool.true + controls_layout: ControlsLayout = ControlsLayout.bottom_right + controls_back_arrows: ControlsBackArrows = ControlsBackArrows.faded + progress: JsBool = JsBool.false + slide_number: SlideNumber = SlideNumber.false + show_slide_number: Union[ShowSlideNumber, Function] = ShowSlideNumber.all + hash_one_based_index: JsBool = JsBool.false + hash: JsBool = JsBool.false + respond_to_hash_changes: JsBool = JsBool.false + history: JsBool = JsBool.false + keyboard: JsBool = JsBool.true + keyboard_condition: Union[KeyboardCondition, Function] = KeyboardCondition.null + disable_layout: JsBool = JsBool.false + overview: JsBool = JsBool.true + center: JsBool = JsBool.true + touch: JsBool = JsBool.true + loop: JsBool = JsBool.false + rtl: JsBool = JsBool.false + navigation_mode: NavigationMode = NavigationMode.default + shuffle: JsBool = JsBool.false + fragments: JsBool = JsBool.true + fragment_in_url: JsBool = JsBool.true + embedded: JsBool = JsBool.false + help: JsBool = JsBool.true + pause: JsBool = JsBool.true + show_notes: JsBool = JsBool.false + auto_play_media: AutoPlayMedia = AutoPlayMedia.null + preload_iframes: PreloadIframes = PreloadIframes.null + auto_animate: JsBool = JsBool.true + auto_animate_matcher: Union[AutoAnimateMatcher, Function] = AutoAnimateMatcher.null + auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease + auto_animate_duration: float = 1.0 + auto_animate_unmatched: JsBool = JsBool.true + auto_animate_styles: List[str] = [ + "opacity", + "color", + "background-color", + "padding", + "font-size", + "line-height", + "letter-spacing", + "border-width", + "border-color", + "border-radius", + "outline", + "outline-offset", + ] + auto_slide: AutoSlide = 0 + auto_slide_stoppable: JsBool = JsBool.true + auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null + default_timing: Union[JsNull, int] = JsNull.null + mouse_wheel: JsBool = JsBool.false + preview_links: JsBool = JsBool.false + post_message: JsBool = JsBool.true + post_message_events: JsBool = JsBool.false + focus_body_on_page_visibility_change: JsBool = JsBool.true + transition: Transition = Transition.none + transition_speed: TransitionSpeed = TransitionSpeed.default + background_transition: BackgroundTransition = BackgroundTransition.none + pdf_max_pages_per_slide: Union[int, str] = "Number.POSITIVE_INFINITY" + pdf_separate_fragments: JsBool = JsBool.true + pdf_page_height_offset: int = -1 + view_distance: int = 3 + mobile_view_distance: int = 2 + display: Display = Display.block + hide_inactive_cursor: JsBool = JsBool.true + hide_cursor_time: int = 5000 + # Add. options + background_color: str = "black" # TODO: use pydantic.color.Color + reveal_version: str = "4.4.0" reveal_theme: RevealTheme = RevealTheme.black - shuffle: JSBool = JSBool.false title: str = "Manim Slides" - width: str = "100%" class Config: use_enum_values = True + extra = "forbid" def get_sections_iter(self) -> Generator[str, None, None]: """Generates a sequence of sections, one per slide, that will be included into the html template.""" @@ -94,17 +285,27 @@ class RevealJS(Converter): file = presentation_config.files[slide_config.start_animation] file = os.path.join(self.assets_dir, os.path.basename(file)) + # TODO: document this + # Videos are muted because, otherwise, the first slide never plays correctly. + # This is due to a restriction in playing audio without the user doing anything. + # Later, this might be useful to only mute the first video, or to make it optional. if slide_config.is_loop(): - yield f'
' + yield f'
' else: - yield f'
' + yield f'
' def load_template(self) -> str: """Returns the RevealJS HTML template as a string.""" + if isinstance(self.template, str): + with open(self.template, "r") as f: + return f.read() return pkg_resources.resource_string( __name__, "data/revealjs_template.html" ).decode() + def open(self, file: str) -> bool: + return webbrowser.open(file) + def convert_to(self, dest: str) -> None: """Converts this configuration into a RevealJS HTML presentation, saved to DEST.""" dirname = os.path.dirname(dest) @@ -138,19 +339,13 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: if not value or ctx.resilient_parsing: return - to = ctx.params.get("to") + to = ctx.params.get("to", "html") - if to: - converter = Converter.from_string(to)(scenes=[]) - for key, value in converter.dict().items(): - click.echo(f"{key}: {repr(value)}") + converter = Converter.from_string(to)(presentation_configs=[]) + for key, value in converter.dict().items(): + click.echo(f"{key}: {repr(value)}") - ctx.exit() - - else: - raise click.UsageError( - "Using --show-config option requires to first specify --to option." - ) + ctx.exit() return click.option( "--show-config", @@ -163,6 +358,35 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: )(function) +def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]: + """Wraps a function to add a `--show-template` option.""" + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + + if not value or ctx.resilient_parsing: + return + + to = ctx.params.get("to", "html") + template = ctx.params.get("template", None) + + converter = Converter.from_string(to)( + presentation_configs=[], template=template + ) + click.echo(converter.load_template()) + + ctx.exit() + + return click.option( + "--show-template", + is_flag=True, + help="Show the template (currently) used for a given conversion format and exit.", + default=None, + expose_value=False, + show_envvar=True, + callback=callback, + )(function) + + @click.command() @click.argument("scenes", nargs=-1) @folder_path_option @@ -187,8 +411,16 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]: "config_options", multiple=True, callback=validate_config_option, - help="Configuration options passed to the converter. E.g., pass `-cbackground_color=red` to set the background color to red (if supported).", + help="Configuration options passed to the converter. E.g., pass `-cshow_number=true` to display slide numbers.", ) +@click.option( + "--use-template", + "template", + metavar="FILE", + type=click.Path(exists=True, dir_okay=False), + help="Use the template given by FILE instead of default one. To echo the default template, use `--show-template`.", +) +@show_template_option @show_config_options @verbosity_option def convert( @@ -199,6 +431,7 @@ def convert( open_result: bool, force: bool, config_options: Dict[str, str], + template: Optional[str], ) -> None: """ Convert SCENE(s) into a given format and writes the result in DEST. @@ -206,11 +439,29 @@ def convert( presentation_configs = get_scenes_presentation_config(scenes, folder) - converter = Converter.from_string(to)( - presentation_configs=presentation_configs, **config_options - ) + try: + converter = Converter.from_string(to)( + presentation_configs=presentation_configs, + template=template, + **config_options, + ) - converter.convert_to(dest) + converter.convert_to(dest) - if open_result: - converter.open(dest) + if open_result: + converter.open(dest) + + except ValidationError as e: + + errors = e.errors() + + msg = [ + f"{len(errors)} error(s) occured with configuration options for '{to}', see below." + ] + + for error in errors: + option = error["loc"][0] + _msg = error["msg"] + msg.append(f"Option '{option}': {_msg}") + + raise click.UsageError("\n".join(msg)) diff --git a/manim_slides/data/revealjs_template.html b/manim_slides/data/revealjs_template.html index b1c155e..534ac02 100644 --- a/manim_slides/data/revealjs_template.html +++ b/manim_slides/data/revealjs_template.html @@ -1,185 +1,291 @@ - - - + + + - {title} + {title} - - + + - - - + + + - - - - + + - -
-
- {sections} -
-
+ +
+
+ {sections} +
+
- - - + - - - + }}); + +