Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>` overlay transitions
- `Scene.export_beamer(output_dir, ...)` — export keyframe PDFs and a compilable Beamer `.tex` file with `\only<N>` 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
Expand Down
24 changes: 23 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>
```

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
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions examples/tikz_beamer_demo.py
Original file line number Diff line number Diff line change
@@ -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}")
103 changes: 101 additions & 2 deletions src/handanim/core/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,25 +596,72 @@ 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,
n_frames: int = 6,
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.
Expand All @@ -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))
Expand Down Expand Up @@ -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.
Expand Down
Loading