diff --git a/CHANGELOG.md b/CHANGELOG.md index 314f20d..41ed0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `Scene.render_keyframes(times, ...)` — batch export snapshots at specific timestamps (single timeline computation) - `Scene.export_storyboard(n_frames, ...)` — export evenly-spaced keyframes as individual SVG/PDF files - `Scene.render_handout(output_path, ...)` — render a single multi-page PDF with one animation frame per page -- `Scene.export_beamer(output_dir, ...)` — export keyframe PDFs and a compilable Beamer `.tex` file with `\only` overlay transitions +- `Scene.export_beamer(output_dir, ...)` — export keyframe PDFs and a compilable Beamer `.tex` file with `\only` overlay transitions; new `backend="tikz"` option emits native TikZ drawing commands instead of embedded PDF images +- `Scene.render_tikz(output_path, ...)` — render a single animation frame as a standalone TikZ/LaTeX file +- `TikZRenderer` (`core/tikz_renderer.py`) — converts OpsSet drawing operations to TikZ path commands; handles coordinate transform (Y-flip), colour deduplication, partial ops, and line width scaling - Top-level `__init__.py` exports — `from handanim import Scene, Rectangle, SketchAnimation` etc. - Tests for all new scene utilities and export methods (35 new tests) - `RotateAnimation` — animates an OpsSet rotating by a configurable angle around an explicit pivot or the center of gravity diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fd96b6a..8142cdb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -264,10 +264,32 @@ scene.render_handout("handout.pdf", times=[0.0, 2.5, 5.0, 8.0, 12.0]) Export keyframe PDFs and a compilable `.tex` file with overlay transitions: ```python +# Cairo backend (default) — embeds rendered PDF images tex_path = scene.export_beamer("output/slides", n_frames=8, title="Pythagorean Theorem") # Then compile: cd output/slides && pdflatex slides.tex ``` +#### Native TikZ backend + +For truly native LaTeX vector output (no external PDF images), use the TikZ backend: + +```python +# TikZ backend — inline drawing commands, no external files +tex_path = scene.export_beamer("output/slides", n_frames=8, backend="tikz", title="My Talk") +# Produces a single .tex file with \begin{tikzpicture} inside each \only +``` + +The TikZ backend converts OpsSet drawing operations to TikZ path commands: `\draw` for strokes, `\fill` for fills, cubic Bezier `controls` for curves, and `\definecolor` for colour management. The hand-drawn roughness is preserved since each wobbly line segment becomes a Bezier curve in TikZ. + +### Standalone TikZ frame + +Export a single animation frame as a compilable standalone TikZ document: + +```python +scene.render_tikz("frame.tex", time=2.5, target_width_cm=10.0) +# pdflatex frame.tex +``` + --- ## Positioning & Timeline Utilities @@ -309,7 +331,7 @@ scene.get_current_time() # returns the end time of the latest event ``` src/handanim/ -├── core/ # OpsSet, Drawable, AnimationEvent, Scene, Viewport, styles +├── core/ # OpsSet, Drawable, AnimationEvent, Scene, Viewport, TikZRenderer, styles ├── primitives/ # Line, Rectangle, Ellipse, Arrow, Text, Math, VectorSVG, RasterImage, … ├── animations/ # SketchAnimation, FadeIn/Out, Zoom, Translate, Rotate, ColorTransition, Camera └── stylings/ # color constants, fill patterns, stroke utilities diff --git a/examples/tikz_beamer_demo.py b/examples/tikz_beamer_demo.py new file mode 100644 index 0000000..8234bea --- /dev/null +++ b/examples/tikz_beamer_demo.py @@ -0,0 +1,92 @@ +"""Demo: export a hand-drawn animation as native TikZ inside a Beamer slide deck. + +Produces: + examples/output/tikz_beamer/slides.tex — compilable with pdflatex + examples/output/tikz_standalone.tex — single-frame standalone TikZ + +Usage: + poetry run python examples/tikz_beamer_demo.py + cd examples/output/tikz_beamer && pdflatex slides.tex + cd examples/output && pdflatex tikz_standalone.tex +""" + +import os + +from handanim.animations import FadeInAnimation, SketchAnimation +from handanim.core import FillStyle, Scene, SketchStyle, StrokeStyle +from handanim.primitives import Circle, Line, NGon, Rectangle, Text +from handanim.stylings.color import BLACK, BLUE, RED + +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "output") + +scene = Scene(width=1920, height=1088) + +title = Text( + text="TikZ Export Demo", + position=(400, 100), + font_size=120, + stroke_style=StrokeStyle(color=BLUE, width=2), +) +scene.add(SketchAnimation(duration=2), drawable=title) + +triangle = NGon( + center=(500, 500), + radius=200, + n=3, + stroke_style=StrokeStyle(color=RED, width=2), + fill_style=FillStyle(color=(1.0, 0.9, 0.85)), + sketch_style=SketchStyle(roughness=2), +) +scene.add(SketchAnimation(start_time=2, duration=2), drawable=triangle) + +rect = Rectangle( + top_left=(900, 350), + width=350, + height=250, + stroke_style=StrokeStyle(color=BLACK, width=2), + fill_style=FillStyle(color=(0.85, 0.92, 1.0)), + sketch_style=SketchStyle(roughness=1.5), +) +scene.add(SketchAnimation(start_time=4, duration=2), drawable=rect) + +connector = Line( + start=(700, 500), + end=(900, 475), + stroke_style=StrokeStyle(color=BLACK, width=1.5), +) +scene.add(SketchAnimation(start_time=6, duration=1), drawable=connector) + +label = Text( + text="Native LaTeX!", + position=(950, 700), + font_size=60, + stroke_style=StrokeStyle(color=BLUE, width=1.5), +) +scene.add(SketchAnimation(start_time=7, duration=1.5), drawable=label) + +# --- TikZ beamer export (native drawing commands, no PDF images) --- +beamer_dir = os.path.join(OUTPUT_DIR, "tikz_beamer") +tex_path = scene.export_beamer( + beamer_dir, + n_frames=6, + backend="tikz", + title="Handanim TikZ Demo", +) +print(f"Beamer slides: {tex_path}") +print(f" Compile with: cd {beamer_dir} && pdflatex slides.tex") + +# --- Standalone TikZ frame (final state) --- +standalone_path = os.path.join(OUTPUT_DIR, "tikz_standalone.tex") +scene.render_tikz(standalone_path, time=8.5) +print(f"Standalone frame: {standalone_path}") +print(f" Compile with: pdflatex {standalone_path}") + +# --- For comparison: cairo backend --- +cairo_dir = os.path.join(OUTPUT_DIR, "cairo_beamer") +cairo_tex = scene.export_beamer( + cairo_dir, + n_frames=6, + backend="cairo", + title="Handanim Cairo Demo", +) +print(f"Cairo slides (for comparison): {cairo_tex}") diff --git a/src/handanim/core/scene.py b/src/handanim/core/scene.py index f406147..23b72cd 100644 --- a/src/handanim/core/scene.py +++ b/src/handanim/core/scene.py @@ -596,6 +596,43 @@ def render_handout( surface.finish() return output_path + def render_tikz( + self, + output_path: str, + time: float = 0.0, + target_width_cm: float = 12.0, + max_length: float | None = None, + ) -> str: + """Render a single animation frame as a standalone TikZ/LaTeX file. + + Args: + output_path: Path to write the ``.tex`` file. + time: Timestamp (in seconds) to render. + target_width_cm: Width of the tikzpicture in centimetres. + max_length: Total animation duration override. + + Returns: + The output file path. + """ + from .tikz_renderer import TikZRenderer + + opsset_list = self.create_event_timeline(max_length) + frame_index = int(np.clip(np.round(time * self.fps), 0, len(opsset_list) - 1)) + + renderer = TikZRenderer(self.viewport, target_width_cm, self.background_color) + tikz_body = renderer.render_tikzpicture(opsset_list[frame_index]) + + tex = ( + "\\documentclass[border=2mm]{standalone}\n" + "\\usepackage{tikz}\n" + "\\begin{document}\n" + f"{tikz_body}\n" + "\\end{document}\n" + ) + with open(output_path, "w") as f: + f.write(tex) + return output_path + def export_beamer( self, output_dir: str, @@ -603,18 +640,28 @@ def export_beamer( times: list[float] | None = None, title: str = "Handanim Slides", max_length: float | None = None, + backend: str = "cairo", + target_width_cm: float = 12.0, ) -> str: """Export animation keyframes as a compilable Beamer/LaTeX slide deck. Each keyframe becomes one overlay on a single frame, so the slides - animate forward when presented. Produces PDF images + a .tex file. + animate forward when presented. + + With ``backend="cairo"`` (default), each keyframe is rendered as a + Cairo PDF and embedded via ``\\includegraphics``. + + With ``backend="tikz"``, keyframes are emitted as inline TikZ + drawing commands — native LaTeX vector graphics, no external files. Args: - output_dir: Directory to write PDFs and the .tex file. + output_dir: Directory to write output files and the .tex file. n_frames: Number of evenly-spaced keyframes (ignored if `times` given). times: Explicit list of timestamps to render. title: Title shown on the Beamer title page. max_length: Total animation duration override. + backend: ``"cairo"`` for PDF-image overlays, ``"tikz"`` for native TikZ. + target_width_cm: Width of tikzpicture (only used with ``backend="tikz"``). Returns: Path to the generated .tex file. @@ -626,6 +673,18 @@ def export_beamer( if times is None: times = [i * total_duration / (n_frames - 1) for i in range(n_frames)] if n_frames > 1 else [0.0] + if backend == "tikz": + return self._export_beamer_tikz(opsset_list, total_frames, times, title, output_dir, target_width_cm) + return self._export_beamer_cairo(opsset_list, total_frames, times, title, output_dir) + + def _export_beamer_cairo( + self, + opsset_list: list, + total_frames: int, + times: list[float], + title: str, + output_dir: str, + ) -> str: pdf_filenames = [] for i, t in enumerate(times): frame_index = int(np.clip(np.round(t * self.fps), 0, total_frames - 1)) @@ -659,6 +718,46 @@ def export_beamer( f.write(tex_content) return tex_path + def _export_beamer_tikz( + self, + opsset_list: list, + total_frames: int, + times: list[float], + title: str, + output_dir: str, + target_width_cm: float, + ) -> str: + from .tikz_renderer import TikZRenderer + + overlay_lines = [] + for i, t in enumerate(times): + frame_index = int(np.clip(np.round(t * self.fps), 0, total_frames - 1)) + renderer = TikZRenderer(self.viewport, target_width_cm, self.background_color) + tikz_body = renderer.render_tikzpicture(opsset_list[frame_index]) + indent = "\n".join(f" {line}" for line in tikz_body.splitlines()) + overlay_lines.append(f" \\only<{i + 1}>{{\n{indent}\n }}") + + overlays = "\n".join(overlay_lines) + + tex_content = ( + "\\documentclass{beamer}\n" + "\\usepackage{tikz}\n" + f"\\title{{{title}}}\n" + "\\date{}\n" + "\\begin{document}\n" + "\\begin{frame}\n" + "\\titlepage\n" + "\\end{frame}\n" + "\\begin{frame}{}\n" + f"{overlays}\n" + "\\end{frame}\n" + "\\end{document}\n" + ) + tex_path = os.path.join(output_dir, "slides.tex") + with open(tex_path, "w") as f: + f.write(tex_content) + return tex_path + def render(self, output_path: str, max_length: float | None = None): """ Render the animation as a video file. diff --git a/src/handanim/core/tikz_renderer.py b/src/handanim/core/tikz_renderer.py new file mode 100644 index 0000000..e5ab8d6 --- /dev/null +++ b/src/handanim/core/tikz_renderer.py @@ -0,0 +1,290 @@ +"""Convert OpsSet drawing operations to TikZ commands for native LaTeX output. + +This module provides a renderer that translates the internal drawing +operation stream (OpsSet) into TikZ path commands. The resulting code +can be embedded directly in a beamer slide or standalone LaTeX document, +producing truly native vector output instead of rasterised PDF images. + +Coordinate convention +--------------------- +handanim world space is Y-down (origin at top-left), while TikZ is Y-up. +The renderer flips Y and scales world units to centimetres so the picture +fits a configurable target width (default 12 cm, matching ``\\textwidth`` +on a 16:9 beamer slide with default margins). +""" + +from __future__ import annotations + +from .draw_ops import OpsSet, OpsType +from .utils import get_bezier_points_from_quadcurve, slice_bezier +from .viewport import Viewport + + +def _fmt(x: float) -> str: + """Format a float, stripping trailing zeros for compact output.""" + s = f"{x:.4f}" + s = s.rstrip("0").rstrip(".") + return s + + +def _coord(x: float, y: float) -> str: + return f"({_fmt(x)},{_fmt(y)})" + + +class TikZRenderer: + """Converts an OpsSet into TikZ drawing commands. + + Parameters + ---------- + viewport : Viewport + The viewport that defines the world coordinate system. + target_width_cm : float + Width of the output tikzpicture in centimetres. + background_color : tuple[float, float, float] | None + Optional RGB background (0-1 range). + precision : int + Decimal digits kept in coordinates and widths. + """ + + def __init__( + self, + viewport: Viewport, + target_width_cm: float = 12.0, + background_color: tuple[float, float, float] | None = None, + precision: int = 4, + ): + self.viewport = viewport + self.background_color = background_color + self.precision = precision + + world_w = viewport.world_xrange[1] - viewport.world_xrange[0] + world_h = viewport.world_yrange[1] - viewport.world_yrange[0] + + scale_x = target_width_cm / world_w + scale_y = (target_width_cm * world_h / world_w) / world_h + self.scale = min(scale_x, scale_y) + self.width_cm = world_w * self.scale + self.height_cm = world_h * self.scale + + self._color_counter = 0 + self._color_cache: dict[tuple[float, float, float], str] = {} + + def _transform(self, x: float, y: float) -> tuple[float, float]: + """Map world coordinates to TikZ coordinates (Y-flipped, cm).""" + tx = (x - self.viewport.world_xrange[0]) * self.scale + ty = (self.viewport.world_yrange[1] - y) * self.scale + return tx, ty + + def _scale_width(self, w: float) -> float: + """Scale a world-unit line width to centimetres.""" + return w * self.scale + + def _get_color_name(self, r: float, g: float, b: float) -> str: + key = (round(r, 3), round(g, 3), round(b, 3)) + if key not in self._color_cache: + self._color_counter += 1 + self._color_cache[key] = f"ha{self._color_counter}" + return self._color_cache[key] + + def _color_definitions(self) -> list[str]: + """Return \\definecolor lines for every colour used so far.""" + lines = [] + for (r, g, b), name in sorted(self._color_cache.items(), key=lambda x: x[1]): + lines.append(f"\\definecolor{{{name}}}{{rgb}}{{{r},{g},{b}}}") + return lines + + # ------------------------------------------------------------------ + # Path building helpers + # ------------------------------------------------------------------ + + def _flush_path( + self, + path_cmds: list[str], + mode: str, + color_name: str | None, + line_width_cm: float | None, + opacity: float, + ) -> str | None: + """Emit a \\draw or \\fill command for the accumulated path.""" + if not path_cmds: + return None + + cmd = "\\fill" if mode == "fill" else "\\draw" + opts: list[str] = [] + if color_name: + opts.append(f"color={color_name}") + if mode != "fill" and line_width_cm is not None: + pts = line_width_cm / 0.03528 # 1pt = 0.03528cm + opts.append(f"line width={_fmt(pts)}pt") + if opacity < 1.0: + key = "fill opacity" if mode == "fill" else "opacity" + opts.append(f"{key}={_fmt(opacity)}") + + opt_str = ", ".join(opts) + path_str = " ".join(path_cmds) + return f" {cmd}[{opt_str}] {path_str};" + + # ------------------------------------------------------------------ + # Main conversion + # ------------------------------------------------------------------ + + def render_opsset(self, opsset: OpsSet) -> list[str]: + """Convert an OpsSet to a list of TikZ drawing command strings. + + Returns a list of strings, each a complete TikZ command + (``\\draw[...] ...;`` or ``\\fill[...] ...;``). + """ + commands: list[str] = [] + path_cmds: list[str] = [] + current_point: tuple[float, float] | None = None + + # pen state + mode = "stroke" + color_name: str | None = None + line_width_cm: float | None = None + opacity: float = 1.0 + + def flush(): + line = self._flush_path(path_cmds, mode, color_name, line_width_cm, opacity) + if line: + commands.append(line) + path_cmds.clear() + + for ops in opsset.opsset: + if ops.type == OpsType.MOVE_TO: + pt = ops.data[0] + tx, ty = self._transform(float(pt[0]), float(pt[1])) + path_cmds.append(_coord(tx, ty)) + current_point = (float(pt[0]), float(pt[1])) + + elif ops.type == OpsType.LINE_TO: + pt = ops.data[0] + x1, y1 = float(pt[0]), float(pt[1]) + if ops.partial < 1.0 and current_point is not None: + x0, y0 = current_point + x1 = x0 + ops.partial * (x1 - x0) + y1 = y0 + ops.partial * (y1 - y0) + tx, ty = self._transform(x1, y1) + path_cmds.append(f"-- {_coord(tx, ty)}") + current_point = (x1, y1) + + elif ops.type == OpsType.CURVE_TO: + cp1 = ops.data[0] + cp2 = ops.data[1] + end = ops.data[2] + p1 = (float(cp1[0]), float(cp1[1])) + p2 = (float(cp2[0]), float(cp2[1])) + p3 = (float(end[0]), float(end[1])) + + if ops.partial < 1.0 and current_point is not None: + sliced = slice_bezier(current_point, p1, p2, p3, ops.partial) + p1, p2, p3 = sliced[0], sliced[1], sliced[2] + + t1 = self._transform(*p1) + t2 = self._transform(*p2) + t3 = self._transform(*p3) + path_cmds.append( + f".. controls {_coord(*t1)} and {_coord(*t2)} .. {_coord(*t3)}" + ) + current_point = (float(end[0]), float(end[1])) if ops.partial >= 1.0 else p3 + + elif ops.type == OpsType.QUAD_CURVE_TO: + q1 = ops.data[0] + q2 = ops.data[1] + q1f = (float(q1[0]), float(q1[1])) + q2f = (float(q2[0]), float(q2[1])) + if current_point is None: + current_point = (0.0, 0.0) + p1, p2, p3 = get_bezier_points_from_quadcurve(current_point, q1f, q2f) + if ops.partial < 1.0: + sliced = slice_bezier(current_point, p1, p2, p3, ops.partial) + p1, p2, p3 = sliced[0], sliced[1], sliced[2] + t1 = self._transform(*p1) + t2 = self._transform(*p2) + t3 = self._transform(*p3) + path_cmds.append( + f".. controls {_coord(*t1)} and {_coord(*t2)} .. {_coord(*t3)}" + ) + current_point = q2f if ops.partial >= 1.0 else p3 + + elif ops.type == OpsType.CLOSE_PATH: + path_cmds.append("-- cycle") + + elif ops.type == OpsType.SET_PEN: + flush() + mode = ops.data.get("mode", "stroke") + color = ops.data.get("color") + if color: + r, g, b = float(color[0]), float(color[1]), float(color[2]) + color_name = self._get_color_name(r, g, b) + opacity = float(ops.data.get("opacity", 1.0)) + width = ops.data.get("width") + if width: + line_width_cm = self._scale_width(float(width)) + + elif ops.type == OpsType.DOT: + flush() + cx, cy = ops.data.get("center", (0, 0)) + radius = float(ops.data.get("radius", 1)) + tx, ty = self._transform(float(cx), float(cy)) + tr = radius * self.scale + dot_opts = [] + if color_name: + dot_opts.append(f"color={color_name}") + if opacity < 1.0: + dot_opts.append(f"fill opacity={_fmt(opacity)}") + opt_str = ", ".join(dot_opts) + commands.append(f" \\fill[{opt_str}] {_coord(tx, ty)} circle ({_fmt(tr)}cm);") + + elif ops.type == OpsType.METADATA: + pass + + # flush any remaining path + flush() + return commands + + def render_tikzpicture(self, opsset: OpsSet) -> str: + """Return a complete ``tikzpicture`` environment string for one frame. + + Includes colour definitions, optional background rectangle, + and all drawing commands. + """ + # register background colour first so it appears in definitions + bg_name: str | None = None + if self.background_color: + r, g, b = self.background_color + bg_name = self._get_color_name(r, g, b) + + draw_cmds = self.render_opsset(opsset) + + lines: list[str] = [] + lines.append("\\begin{tikzpicture}") + + for cdef in self._color_definitions(): + lines.append(f" {cdef}") + + w, h = _fmt(self.width_cm), _fmt(self.height_cm) + lines.append(f" \\useasboundingbox (0,0) rectangle ({w},{h});") + + if bg_name: + lines.append(f" \\fill[{bg_name}] (0,0) rectangle ({w},{h});") + + lines.extend(draw_cmds) + lines.append("\\end{tikzpicture}") + return "\n".join(lines) + + def reset_colors(self): + """Clear the colour cache between frames to keep definitions local.""" + self._color_counter = 0 + self._color_cache.clear() + + +def opsset_to_tikz( + opsset: OpsSet, + viewport: Viewport, + target_width_cm: float = 12.0, + background_color: tuple[float, float, float] | None = None, +) -> str: + """Convenience: convert a single OpsSet to a tikzpicture string.""" + renderer = TikZRenderer(viewport, target_width_cm, background_color) + return renderer.render_tikzpicture(opsset) diff --git a/tests/core/test_tikz_renderer.py b/tests/core/test_tikz_renderer.py new file mode 100644 index 0000000..194c6b0 --- /dev/null +++ b/tests/core/test_tikz_renderer.py @@ -0,0 +1,351 @@ +"""Tests for the TikZ rendering backend.""" + +import os +import subprocess + +import pytest + +from handanim.animations import FadeInAnimation, SketchAnimation +from handanim.core import FillStyle, Scene, SketchStyle, StrokeStyle +from handanim.core.draw_ops import Ops, OpsSet, OpsType +from handanim.core.tikz_renderer import TikZRenderer, opsset_to_tikz +from handanim.core.viewport import Viewport +from handanim.primitives import Circle, Line, NGon, Rectangle + + +@pytest.fixture +def viewport(): + return Viewport(world_xrange=(0, 1000), world_yrange=(0, 750)) + + +@pytest.fixture +def renderer(viewport): + return TikZRenderer(viewport, target_width_cm=10.0) + + +# ------------------------------------------------------------------ # +# TikZRenderer unit tests +# ------------------------------------------------------------------ # + +class TestCoordinateTransform: + def test_origin_maps_to_bottom_left(self, renderer, viewport): + tx, ty = renderer._transform(0, 750) + assert abs(tx) < 0.001 + assert abs(ty) < 0.001 + + def test_top_right_maps_to_top_right(self, renderer, viewport): + tx, ty = renderer._transform(1000, 0) + assert abs(tx - renderer.width_cm) < 0.001 + assert abs(ty - renderer.height_cm) < 0.001 + + def test_y_is_flipped(self, renderer): + _, y_top = renderer._transform(500, 0) + _, y_bottom = renderer._transform(500, 750) + assert y_top > y_bottom + + def test_aspect_ratio_preserved(self, renderer): + assert abs(renderer.width_cm / renderer.height_cm - 1000 / 750) < 0.01 + + +class TestColorCache: + def test_same_color_reuses_name(self, renderer): + n1 = renderer._get_color_name(1.0, 0.0, 0.0) + n2 = renderer._get_color_name(1.0, 0.0, 0.0) + assert n1 == n2 + + def test_different_colors_get_different_names(self, renderer): + n1 = renderer._get_color_name(1.0, 0.0, 0.0) + n2 = renderer._get_color_name(0.0, 1.0, 0.0) + assert n1 != n2 + + def test_color_definitions_output(self, renderer): + renderer._get_color_name(0.5, 0.5, 0.5) + defs = renderer._color_definitions() + assert len(defs) == 1 + assert "\\definecolor" in defs[0] + assert "0.5,0.5,0.5" in defs[0] + + def test_reset_clears_cache(self, renderer): + renderer._get_color_name(1.0, 0.0, 0.0) + renderer.reset_colors() + assert len(renderer._color_cache) == 0 + + +class TestRenderOpsset: + def test_empty_opsset(self, renderer): + opsset = OpsSet([]) + cmds = renderer.render_opsset(opsset) + assert cmds == [] + + def test_line(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(100, 100)]), + Ops(OpsType.LINE_TO, [(200, 200)]), + ]) + cmds = renderer.render_opsset(ops) + assert len(cmds) == 1 + assert "\\draw" in cmds[0] + assert "--" in cmds[0] + + def test_curve(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.CURVE_TO, [(100, 0), (200, 100), (300, 100)]), + ]) + cmds = renderer.render_opsset(ops) + assert len(cmds) == 1 + assert "controls" in cmds[0] + + def test_closed_path(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 0)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + Ops(OpsType.CLOSE_PATH, []), + ]) + cmds = renderer.render_opsset(ops) + assert "cycle" in cmds[0] + + def test_fill_mode(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (1, 0, 0), "width": 1, "opacity": 1, "mode": "fill"}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 0)]), + Ops(OpsType.LINE_TO, [(50, 50)]), + Ops(OpsType.CLOSE_PATH, []), + ]) + cmds = renderer.render_opsset(ops) + assert "\\fill" in cmds[0] + + def test_opacity_in_output(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 0.5}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + ]) + cmds = renderer.render_opsset(ops) + assert "opacity=0.5" in cmds[0] + + def test_full_opacity_omitted(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1.0}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + ]) + cmds = renderer.render_opsset(ops) + assert "opacity" not in cmds[0] + + def test_dot(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (1, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.DOT, {"center": (500, 375), "radius": 5}), + ]) + cmds = renderer.render_opsset(ops) + assert any("circle" in c for c in cmds) + + def test_multiple_pen_changes(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (1, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 0)]), + Ops(OpsType.SET_PEN, {"color": (0, 0, 1), "width": 2, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(200, 0)]), + Ops(OpsType.LINE_TO, [(300, 0)]), + ]) + cmds = renderer.render_opsset(ops) + assert len(cmds) == 2 + + def test_partial_line(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 0)], partial=0.5), + ]) + cmds = renderer.render_opsset(ops) + assert len(cmds) == 1 + # endpoint should be at ~(50, 0) in world coords + assert "\\draw" in cmds[0] + + def test_metadata_ignored(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.METADATA, {"drawing_mode": "fill"}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + ]) + cmds = renderer.render_opsset(ops) + assert len(cmds) == 1 + assert "metadata" not in cmds[0].lower() + + +class TestTikzpicture: + def test_contains_begin_end(self, renderer): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(0, 0)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + ]) + result = renderer.render_tikzpicture(ops) + assert result.startswith("\\begin{tikzpicture}") + assert result.endswith("\\end{tikzpicture}") + + def test_has_bounding_box(self, renderer): + ops = OpsSet([]) + result = renderer.render_tikzpicture(ops) + assert "\\useasboundingbox" in result + + def test_background_fill(self, viewport): + renderer = TikZRenderer(viewport, background_color=(1.0, 1.0, 1.0)) + result = renderer.render_tikzpicture(OpsSet([])) + assert "\\fill" in result + assert "rectangle" in result + + def test_no_background_when_none(self, renderer): + result = renderer.render_tikzpicture(OpsSet([])) + lines = result.splitlines() + fill_lines = [l for l in lines if "\\fill" in l] + assert len(fill_lines) == 0 + + +class TestConvenienceFunction: + def test_opsset_to_tikz(self, viewport): + ops = OpsSet([ + Ops(OpsType.SET_PEN, {"color": (0, 0, 0), "width": 1, "opacity": 1}), + Ops(OpsType.MOVE_TO, [(50, 50)]), + Ops(OpsType.LINE_TO, [(100, 100)]), + ]) + result = opsset_to_tikz(ops, viewport) + assert "\\begin{tikzpicture}" in result + assert "\\draw" in result + + +# ------------------------------------------------------------------ # +# Scene integration tests +# ------------------------------------------------------------------ # + +class TestSceneRenderTikz: + def test_render_tikz_creates_file(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + out = scene.render_tikz(str(tmp_path / "out.tex"), time=0.5) + assert os.path.exists(out) + + def test_render_tikz_standalone_structure(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + out = scene.render_tikz(str(tmp_path / "out.tex"), time=1.0) + with open(out) as f: + content = f.read() + assert "\\documentclass" in content + assert "\\usepackage{tikz}" in content + assert "\\begin{document}" in content + assert "\\begin{tikzpicture}" in content + + def test_render_tikz_target_width(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + out = scene.render_tikz(str(tmp_path / "out.tex"), time=1.0, target_width_cm=8.0) + with open(out) as f: + content = f.read() + assert "8" in content + + +class TestExportBeamerTikz: + def test_tikz_backend_produces_tex(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=3, backend="tikz") + assert os.path.exists(tex) + + def test_tikz_backend_no_pdf_files(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + scene.export_beamer(str(tmp_path), n_frames=3, backend="tikz") + pdf_files = [f for f in os.listdir(str(tmp_path)) if f.endswith(".pdf")] + assert len(pdf_files) == 0 + + def test_tikz_backend_has_overlays(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=4, backend="tikz") + with open(tex) as f: + content = f.read() + for i in range(1, 5): + assert f"\\only<{i}>" in content + assert "\\begin{tikzpicture}" in content + assert "\\includegraphics" not in content + + def test_tikz_backend_uses_tikz_package(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=2, backend="tikz") + with open(tex) as f: + content = f.read() + assert "\\usepackage{tikz}" in content + assert "\\usepackage{graphicx}" not in content + + def test_cairo_backend_still_works(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=3, backend="cairo") + with open(tex) as f: + content = f.read() + assert "\\includegraphics" in content + pdf_files = [f for f in os.listdir(str(tmp_path)) if f.endswith(".pdf")] + assert len(pdf_files) == 3 + + def test_default_backend_is_cairo(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=2) + with open(tex) as f: + content = f.read() + assert "\\includegraphics" in content + + +class TestTikzCompilation: + """These tests require pdflatex. Skip if not installed.""" + + @pytest.fixture(autouse=True) + def check_pdflatex(self): + result = subprocess.run(["pdflatex", "--version"], capture_output=True) + if result.returncode != 0: + pytest.skip("pdflatex not available") + + def test_standalone_compiles(self, tmp_path): + scene = Scene(width=400, height=300) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=r) + tex = scene.render_tikz(str(tmp_path / "test.tex"), time=1.0) + result = subprocess.run( + ["pdflatex", "-interaction=nonstopmode", "-output-directory", str(tmp_path), tex], + capture_output=True, timeout=30, + ) + assert result.returncode == 0 + assert (tmp_path / "test.pdf").exists() + + def test_beamer_tikz_compiles(self, tmp_path): + scene = Scene(width=400, height=300) + l = Line(start=(50, 50), end=(350, 50)) + r = Rectangle(top_left=(100, 100), width=200, height=100) + scene.add(SketchAnimation(start_time=0, duration=1), drawable=l) + scene.add(SketchAnimation(start_time=1, duration=1), drawable=r) + tex = scene.export_beamer(str(tmp_path), n_frames=3, backend="tikz", title="Test") + result = subprocess.run( + ["pdflatex", "-interaction=nonstopmode", "-output-directory", str(tmp_path), tex], + capture_output=True, timeout=30, + ) + assert result.returncode == 0 + assert (tmp_path / "slides.pdf").exists() diff --git a/todo.md b/todo.md index ece7bd5..0d0bd21 100644 --- a/todo.md +++ b/todo.md @@ -131,6 +131,7 @@ Goal: make handanim output usable beyond standalone MP4 — in slides, web pages ### Slide deck export - [x] **Beamer/LaTeX overlay export** — `scene.export_beamer()` exports keyframe PDFs + generates a compilable `slides.tex` with `\only` overlays. *(Difficulty: medium)* +- [x] **Native TikZ backend for Beamer** — `scene.export_beamer(backend="tikz")` emits inline TikZ drawing commands instead of embedded PDF images. Also adds `scene.render_tikz()` for standalone TikZ output. `TikZRenderer` converts OpsSet ops (lines, curves, fills, dots, opacity) to TikZ paths with coordinate transform, colour deduplication, and partial-op support. Compiles cleanly with pdflatex. *(Difficulty: medium)* - [ ] **reveal.js / HTML export (embed approach)** — export per-slide MP4/GIF clips and embed them into a reveal.js HTML deck. No client-side animation replay, just embedded media. *(Difficulty: medium — template generation + splitting video at keyframe boundaries)* - [ ] **reveal.js / HTML export (animated SVG replay)** — build a JS-based player that replays the OpsSet timeline in the browser using SVG or Canvas. Would need a completely separate rendering path from Cairo — essentially a second renderer translating OpsSet ops into DOM SVG elements with timed playback. *(Difficulty: hard — Cairo SVG output is flat with no semantic structure matching OpsSet ops)* - [ ] **SVG+CSS vs SVG+JS animation investigation** — determine whether CSS `@keyframes` on SVG path elements can approximate stroke-by-stroke sketch animation, or whether a small JS player reading a timeline JSON is more practical. *(Difficulty: hard — research/prototyping, uncertain output quality)*