Add animated HTML export#21
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Canvas.to_html()and.html/.htmdispatch forCanvas.render, producing fixed-stage HTML with inline CSS, JS, fonts, images, and packaged runtime assets loaded via package resources.Deck.to_html()and.htmldispatch forDeck.render, with click and keyboard navigation, per-slide timelines, auto-advance metadata, and slide transition playback.quickthumb/html/*runtime/template assets to package builds while keeping generated HTML self-contained.slide_effects_deckexample files.