Skip to content

Add animated HTML export#21

Merged
sjquant merged 36 commits into
mainfrom
claude/html-support-animations-1srqma
Jul 2, 2026
Merged

Add animated HTML export#21
sjquant merged 36 commits into
mainfrom
claude/html-support-animations-1srqma

Conversation

@sjquant

@sjquant sjquant commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Why

quickthumb can already render static images, SVG, PPTX, and PDF. This adds a web export target that keeps the same fixed-layout rendering contract while allowing layer animations and deck transitions to play in a browser. Exports remain self-contained so users can open them directly, including from file://.

Changes

  • Adds Canvas.to_html() and .html / .htm dispatch for Canvas.render, producing fixed-stage HTML with inline CSS, JS, fonts, images, and packaged runtime assets loaded via package resources.
  • Adds Deck.to_html() and .html dispatch for Deck.render, with click and keyboard navigation, per-slide timelines, auto-advance metadata, and slide transition playback.
  • Maps supported layer animations and slide transitions to CSS keyframes and a small timeline runtime while preserving original opacity/clip state and blocking overlapping rapid-navigation animations.
  • Extends native HTML output for backgrounds, shapes, text, gradients, SVG filters, font embedding, and raster fallback for content that needs pixel-exact rendering.
  • Adds quickthumb/html/* runtime/template assets to package builds while keeping generated HTML self-contained.
  • Updates docs and examples, adds the investor deck HTML/PPTX example output, and removes the obsolete slide_effects_deck example files.
  • Adds black-box HTML export tests, runtime smoke coverage, transition/radial-gradient coverage, and package resource loading coverage.

claude and others added 30 commits June 28, 2026 14:01
Render any Canvas to a self-contained HTML document (inline CSS, JS, fonts,
and images) built on the shared export layout, so it stays a faithful twin of
the PNG/SVG/PDF/PPTX output. The composition is a fixed-size stage that never
reflows; with responsive=True (default) it is scaled as one unit to fill the
viewport.

Backgrounds, gradients, outlines, shapes, and text map to native HTML/CSS;
raster images, blend modes, image glyph fills, and custom layers embed as
pixel-exact PNG fragments. Per-layer animations map to CSS keyframes driven by
a small JS timeline runtime honouring on_click/with_previous/after_previous
sequencing, and Deck.to_html produces a navigable slideshow with slide
transitions -- the only format that actually plays animations.

Adds Canvas.to_html, Deck.to_html, .html/.htm render dispatch, tests, and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
Replace the _document() f-string assembly with a Jinja2 template
(quickthumb/templates/document.html) that owns HTML structure: the
document shell, stage divs, and the responsive/deck layout toggles.
Empty {% block %} hooks (head_extras, body_extras, scripts) are wired
in for a future server mode. Computed values (CSS, JS runtime, layer
HTML) remain in Python. Stage.markup() is removed; the template renders
stages directly via a new timeline_json property.
…lates

- Move _fmt to _export_base so SVG and HTML exporters share one copy
- Extract _css_gradient as standalone function (was HtmlExporter method)
- Cache _doc_tmpl at module level; use cached_property for Stage.timeline_json
- Replace %RESPONSIVE% string hack with Jinja2 _env.from_string() templates
- Remove dead img-tag branch in _append_element; rename temp var made→element_id
- Unparenthesize tuple unpack: paste_x, paste_y = x, y
Three bugs in the animation runtime:
- settle() overwrote polygon clip-paths after any entrance animation by
  setting clipPath='none'; now saves origClip per element at construction
  and restores it in settle() and reset()
- Deck keydown (ArrowRight/Space) jumped directly to the next slide without
  first advancing the current slide's animations; now mirrors the click handler
- Back-navigation (go to earlier slide) replayed autoLead from a stale cursor
  position; show() now calls timelines[i].reset() before start()

Also add examples/slide_effects_html.py — same four-slide deck as the PPTX
example, rendered to a self-contained browser slideshow.
- Cache all querySelector results at qtTimeline construction into elMap,
  eliminating repeated DOM lookups in the per-animation play() hot path
- Add will-change:clip-path,opacity before each animation and clear it
  in settle() so the browser promotes GPU layers only while needed
- Read stage dimensions from style.width/height (string parse) in qtFit
  instead of offsetWidth/offsetHeight to avoid layout reads on resize
- Use ResizeObserver exclusively when available; fall back to
  window.resize only on older browsers to prevent double-firing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
Deck slide transitions (Push, Wipe, Cover, Uncover, Zoom, Split, Fade,
Cut, Circle, …) previously only affected PPTX. The HTML slideshow now
animates each slide change with its transition: the incoming stage plays
a CSS keyframe derived from the effect, with transform-based effects
composed against the responsive fit scale via scale(var(--qt-scale)) so
scale-to-viewport survives the animation (verified to re-resolve on
resize). Slides without a transition keep the prior 0.5s cross-fade.

Exotic effects (wheel, wedge, checker, comb, dissolve) fall back to the
closest CSS analogue, mirroring how the per-layer animations already
degrade for HTML.

Also fix a text overlap in the investor deck: the Traction headline sat
on top of the "TRACTION" label; nudged it down to clear the label. The
deck now sets a distinct transition per slide to showcase the feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
Slide transitions blanked the previous slide instantly, so the incoming
slide animated in over a black void. Make them two-slide: the outgoing
slide stays on screen — static beneath the incoming one for cover, fade,
wipe, zoom and the clip reveals, or sliding out for push and uncover —
so a push reads as the old slide leaving while the new one arrives, with
no gap. Stages are now absolutely centred so they can overlap, with
z-order chosen per effect and the off-screen slides dropped once the
change completes.

Promote both stages with will-change for the duration of the change so
transform/opacity transitions composite on the GPU instead of repainting
on the main thread, which is what made them feel laggy. The promotion is
cleared on settle so nothing stays on its own layer longer than needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
The HTML exporter references font families by name but only inlined the
font files when embed_fonts=True, which defaulted to False. So a deck
opened on a machine without the design fonts (NotoSerif, Roboto, …) fell
back to generic system fonts, with visibly different letterforms — a
large, jarring departure from the PNG/PPTX render even though the layout
math is identical.

Default embed_fonts to True for Canvas.to_html and Deck.to_html so the
exported document carries its fonts as @font-face data URLs and renders
identically everywhere, restoring the "faithful twin" promise. Pass
embed_fonts=False for a smaller file that relies on the viewer's fonts.

Verified by screenshotting the settled HTML and diffing against the
canonical raster render: embedding roughly halves the pixel difference,
and the remaining delta is drop-shadow softness and glyph antialiasing
(inherent to browser vs PIL rasterization).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
Two systematic differences made HTML text/shapes drift from the
PNG/PPTX render even with fonts embedded:

- Gradient extent. The raster engine ramps a gradient across the box
  *diagonal*, centres it, and crops — so a box shows only the middle
  slice of the colour range, and a multi-line text block carries one
  continuous gradient. The HTML exporter instead mapped 0–100% across
  each element (and restarted per line), so colours were over-saturated
  and discontinuous. Remap the CSS stops to reproduce the diagonal,
  centred mapping against whatever box CSS actually paints (the line
  span for text, the box itself for shapes/backgrounds), so each line
  shows its true slice of the shared gradient.

- Shadow blur. PIL's Gaussian blur radius is its standard deviation,
  but CSS defines a shadow's blur as twice the standard deviation, so
  every shadow/glow came out half as soft. Double the radius when
  emitting text-shadow, box-shadow and drop-shadow.

Also pin text to grayscale antialiasing to avoid subpixel colour
fringing on platforms that would otherwise use it. Verified by
screenshotting settled slides and diffing against the raster render:
the mean pixel difference drops on every slide and gradient interiors
now match; the residual is glyph-edge antialiasing, inherent to browser
vs PIL rasterisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
CSS box-shadow/text-shadow/drop-shadow could not match the raster
engine's effects: they have no dilation (so a glow couldn't grow the
silhouette the way PIL does), their blur radius is twice the Gaussian
standard deviation (PIL's is exactly the sigma), and a clip-path shape
had no real outline (stroke was faked with four stacked drop-shadows).

Emit one shared SVG <filter> per distinct effect set instead, built from
the same primitives the engine uses: Shadow = feGaussianBlur(sigma =
blur_radius) + feOffset + feFlood (honouring the colour's alpha, which
PIL applies); Glow = feGaussianBlur + opacity (text also feMorphology-
dilates first); Stroke = feMorphology dilate by its width. feGaussianBlur
stdDeviation is the sigma, so no blur fudge factor is needed, and the
filters dedupe by a content hash and live in one inline <svg><defs>.

Verified by screenshotting settled slides and diffing against the raster
render: the >30 difference area on the cover drops from 7.8% to 2.7% and
mean pixel error roughly halves across the deck, with the remaining delta
being only glyph-edge antialiasing. The old per-shadow CSS blur helper is
removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
Cleanup pass over the HTML export changes (no behaviour change):

- Collapse the dead branches in _transition_states. _transition_plan
  already handles cut, push and uncover, so those arms were unreachable
  and only cover ever reached the directional branch; trim to cover and
  drop the now-impossible None return (and the guard that checked for it).
- De-duplicate the direction geometry: one shared _WIPE_INSETS table for
  both layer wipe/blinds effects and slide wipe/comb/blinds transitions,
  and reuse the _DIR_IN/_HOME constants in the cover case instead of a
  third inline off-screen map. Hoist the direction constants above their
  users.
- _register_animation now returns just the style string; the second
  tuple element (a "hidden" bool) was discarded by every caller and
  computed twice. Inline the single-use _layer_animations helper into it.
- Build deck transition keyframes in a list joined once instead of
  repeated string concatenation in the per-slide loop.

Verified: full suite green and the deck transitions still animate (two
slides overlap, no JS errors) in a headless browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
_register_font (family/weight/style resolution) and the @font-face CSS
builder were copy-pasted between the SVG and HTML exporters, so the same
font logic had to be kept in sync in two places by hand.

Extract both into quickthumb/_export_base.py as resolve_font_face() and
font_face_declarations(), following the existing precedent of
read_svg_layer_bytes_and_size() in the same module. Each exporter now
calls the shared resolver and only keeps its own formatting: HTML embeds
per its embed_fonts flag and uses the bare family name, SVG always
records for embedding and appends the ", sans-serif" fallback plus its
own <style> wrapper.

No behavior change; verified via the existing HTML/SVG/PPTX test suites
(507 passing) and a direct check that both exporters still embed fonts
identically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01427RCDokkGSRT5ySDpuN55
@sjquant sjquant changed the title Add HTML renderer with animations and deck slideshow Add animated HTML export Jul 2, 2026
@sjquant sjquant merged commit 06e7fef into main Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants