A gradient on text was read but drawn in its first colour. Each line is now
filled with the whole gradient, spanning that line's text from its top to
its bottom and its first to its last character, as PowerPoint does. The
gradient builder moves out of fill_path into paint::gradient so fills and
text share it (and PowerPoint's two-colour ramp). Gradient colours are
resolved when the text style is made.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Emoji: colour glyphs, and emoji take the colour emoji font
Emoji were drawn as black outlines from a symbol font, with ZWJ sequences
(families, flags) falling apart into their parts. Now:
- an emoji cluster (emoji presentation, U+FE0F, ZWJ between pictographs,
skin tones, regional indicators, keycaps) takes an installed colour font
(COLR, CBDT, sbix or SVG tables) before the document's fonts, so its
sequences ligate as in PowerPoint; U+FE0E keeps a plain face;
- COLR v0 and v1 glyphs are painted in their CPAL colours (solid fills,
linear, radial and sweep gradients, transforms, composite modes), with
palette index 0xFFFF in the text colour.
A tiny COLRv0 test font (two squares, CC0, regenerated by
make_colr_font.py) backs the render test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Render: lock skrifa for colour glyphs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PowerPoint shows white around a background picture stretched into part of
the slide and under a see-through background colour. DeckCraft left those
areas transparent, which exports and the slide show draw black.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A shape with useBgFill="1" is a window onto the slide's background, laid
out on the slide. DeckCraft drew nothing in it, so a master that covers the
background with a white panel and cuts a background-filled frame into it
(tdf93868) rendered all white.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PowerPoint blends a gradient of exactly two stops at 0% and 100% in linear
light, with the second colour's share following an S-shaped curve (black to
white is 105/186/238 at a quarter/half/three quarters, not 64/128/191).
Gradients with more stops, or a stop moved off an end, stay linear in sRGB.
Measured with PowerPoint's slide export on shapes and backgrounds, in both
directions. The two-colour case is expanded into sixteen pieces per
segment so vello's sRGB interpolation draws the same ramp.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every datetime field showed the deck-wide fixed date text instead of a date: empty when no slide
had a fixed date, and that text on every slide when one did. A fixed date is plain text in its
slide's placeholder; a field is an automatic date, so it now shows the local date and time in the
format of its type (ECMA-376 datetime1 to datetime13 as PowerPoint lists them for English,
datetime and datetimeFigureOut the short date). Numeric dates follow the run's language
(en-GB 09/10/2026, de-DE 09.10.2026...); formats with names stay English-only, so other languages
keep the field's saved text, as do unknown types and builds without a clock (wasm). The saved
text is kept on save.
- text: datetime module (field types, formats, local clock via chrono, not on wasm);
Fields::field_in passes the run's language.
- render: RenderOpts::now injects the clock (tests, deterministic output); the PDF text layer
uses the same fields.
- engine: Header & Footer turns existing date placeholders into fixed text or back into a
field; new date fields are saved with the date they show (local, not UTC).
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* feat(pictures): animated GIFs play, with pause (media.gifPlay)
GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(pictures): animated GIFs play, with pause (media.gifPlay)
GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.
Also adds my entry to contributors/people.toml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Animated GIFs: cap frames (1000), canvas (4096²), per-GIF (64 MB) and cache (192 MB) memory
A few MB of hostile GIF could decode to millions of tiny frames or a 100 Mpx canvas per frame,
and eight cached GIFs could hold 2 GB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Morph: a changed geometry morphs its outline (a circle turns into a star)
Paired shapes that differ only in geometry (another preset, other adjust
values, a custom path) and fill colour no longer cross-fade: one shape moves
and its outline turns from the old geometry into the new one.
- geom::morph aligns two outlines the way manim does: the same number of
contours, the same number of cubic curves per contour pair, then every
point moves in a straight line. Closed contours are turned and reversed so
the points travel least (a triangle does not twist into a circle), extra
points go to the longest curves, and a sub-path without partner grows out
of (or shrinks into) a point, so a cube's faces appear and a donut's hole
closes. Fill mode, outline and fill rule switch at the midpoint.
- anim::morph_frame lists such shapes in MorphFrame::paths; the renderer
draws them with the morphed geometry (PathMorph, a new render_blend
argument next to TextMorph).
Ported from the path morph in alexplain (MIT, same author).
generated with Claude Code
* Morph: differing looks of an outline cross-fade instead of switching
When a sub-path pair differs in fill mode, outline or fill rule, the two
looks no longer switch at the midpoint. A fill or outline only one side has
fades out (or in), so a filled shape turning into a line loses its fill
gradually. When both fill, the even-odd side lies opaque underneath and the
other fades over it, so a donut's hole fills smoothly while it shrinks.
generated with Claude Code
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full
transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down
(flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back
about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation
still applies, and the same holds for shapes inside flipped groups.
render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text
layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret
all use it, so a click lands on the character drawn under it.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Text with a:hlinkClick (to a URL or a slide) looked like the text around it. As in PowerPoint,
a linked run now takes the theme's hlink colour and a single underline, unless the run itself
sets a colour (PowerPoint 2019 and later honour it) or an underline (u="none" removes it).
Colours from list and master styles don't count, so links in titles are blue too.
This is part of run resolution, so links added with insert.hyperlink look the same, Format
shows them underlined, and turning underline off writes u="none". The run's own props are left
alone, so the look follows the theme.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Tables: rows grow to fit their text, cell text in the PDF text layer (#13)
A stored row height (a:tr/@h) is a minimum, as in PowerPoint: each row grows so its cells' text fits. Tables from pandoc or python-pptx store h="0" and drew every row on top of each other. Cell editing (click to edit, cell outline) uses the same heights, and the PDF text layer now includes table cell text.
* Tables: a grown table's box covers its drawn rows; resize scales from drawn heights
Handles, hit tests and marquee used the stored frame, so rows grown to fit their text drew
below the selection box (a file with h=0 rows had a 0 pt tall box). Resizing divided by the
stored row total, which is ~0 for those files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Morph: shapes move, resize and blend in the slide show
Morph used to fall back to a plain crossfade in the show: morph_pairs and
morph_xfrm existed, but nothing rendered frames with them.
- anim: morph_frame builds a frame from two slides. Paired shapes move along
morph_xfrm (eased); a pure fill colour change blends, other content changes
cross-fade on the same moving box. Unpaired shapes fade out/in, shapes with
entrance animations or hidden media stay out of the frame.
- render: render_blend draws such a frame in one pass: both backdrops
(background, master and layout graphics) cross-fade, every shape resolves
against the slide it comes from, so placeholders keep their own layout.
Renderer::slide is split into canvas/fields/backdrop to share the code.
- ui-egui: the show renders morph frames with these, crossfade otherwise.
generated with Claude Code
* Contributors: add my own entry (alexanderalber)
generated with Claude Code
* Morph: Words and Characters options morph text token by token
The transition option (objects/words/characters) was stored and round-tripped
but ignored by the show. With Words or Characters, paired shapes whose text
differs now morph their text instead of cross-fading it:
- text: GlyphRun records per glyph the paragraph character it draws, so
glyphs can be grouped into words or characters.
- anim: morph_frame reads the option and lists such pairs in MorphFrame::text.
- render: morph_text draws them. Shared words (or characters) travel from their
place in the old text to their place in the new one and grow or shrink to the
new size; a different look cross-fades on the way. The remaining tokens fade
while following a scale and shift fitted to the shared ones, so a line moves
and rearranges as one. Rotated text falls back to a cross-fade.
draw_layout_with's glyph drawing moved into draw_glyph for reuse.
- ui-egui: the show passes the pairs to render_blend.
generated with Claude Code
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>