Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6702ec8878 Text: shadows and glows are drawn
Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but
never drawn. The text layout now carries them on each glyph run (colours
resolved, sizes scaled with autofit) and the renderer draws every run's
shadow, then every glow, blurred, under the text. A text glow is solid to
about a third of its radius past the letters and fades out by two thirds,
as measured on PowerPoint's export. needs_filters also looks at text
effects (runs, list styles, master text styles), so they render with the
blur filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:51:50 -04:00
Brandon ThomasandClaude Opus 5.5 1bb86db54a Text: gradient fills draw, starting over on every line (#281)
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>
2026-10-12 17:51:20 -04:00
Brandon ThomasandClaude Opus 5.5 ba56742fa9 Emoji: drawn in colour, with families, skin tones and flags joined (#278)
* 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>
2026-10-12 17:48:51 -04:00
Brandon ThomasandClaude Opus 5.5 8b160d5b03 Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276)
* Text: wrap lines where PowerPoint does

- Without latinLnBrk (PowerPoint's default) a word doesn't break after '/'
  or '\': URLs and paths fill the line and break at its edge.
- Korean wraps between words, not between syllables.
- With hangingPunct, closing East Asian punctuation hangs past the end of
  the line instead of dragging the previous character down.

ParaProps gains latin_line_break and hanging_punct, read and written as
latinLnBrk and hangingPunct.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: simpler break guard (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:47:12 -04:00
Brandon ThomasandClaude Opus 5.5 39a8234633 Render: slide backgrounds are laid over white (#275)
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>
2026-10-12 17:45:31 -04:00
Brandon ThomasandClaude Opus 5.5 98ea8d3195 Render: shapes filled with the background (useBgFill) show it (#274)
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>
2026-10-12 17:44:20 -04:00
Brandon ThomasandClaude Opus 5.5 2e68504e7c Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint (#266)
* Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint

PowerPoint spaces single-spaced lines 1.2 x the font size apart in every
font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt
with PowerPoint's slide export) and puts the baseline where the font's
Windows ascent/descent split it. DeckCraft used each font's own ascent plus
descent, so Calibri lines were 15% too tight and Segoe UI ones too loose.

FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent));
line_metrics uses it with LINE_PITCH = 1.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: tidy the line pitch test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: line_split guard reads plainly (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: note that the 1.2 line pitch was measured on one Windows install

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:42:53 -04:00
Brandon ThomasandClaude Opus 5.5 e6050b7d37 Text: a hang past the box edge keeps its width on the first line (#265)
When marL + indent is negative, PowerPoint pins the bullet to the box's edge
and starts the first line's text at -indent; DeckCraft put the text right
after the number, so numbered lists written with indent=-457200 and no marL
lost their gap.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:01 -04:00
Brandon ThomasandClaude Opus 5.5 72d8e0ab42 Draw only the first character of a multi-character bullet (#307)
PowerPoint draws buChar char="••" (seen in a LibreOffice test deck) as a
single dot; DeckCraft drew both, pushing the text right.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:44 -04:00
Brandon ThomasandClaude Opus 5.5 2347a53845 PPTX: a placeholder without geometry takes its layout's (#301)
PowerPoint leaves an inheriting placeholder's spPr without a geometry and
draws it with its layout (or master) placeholder's: a picture placeholder
cut into a shape, or with holes, keeps that shape on every slide. DeckCraft
read such placeholders as rectangles. They're now marked while reading
and given their parent's geometry once the layouts are read; anything
without a parent stays a rectangle. DeckCraft itself already writes
inheriting placeholders this way.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:37 -04:00
Brandon ThomasandClaude Opus 5.5 692aa0103b PPTX: a picture fill with no stretch or tile tiles (#290)
A shape or background blipFill with neither a:stretch nor a:tile (as some
tools write it, tdf#128596) was stretched across the shape; PowerPoint
tiles the picture at its own size.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:28 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6e3b990 Render: two-colour gradients ramp like PowerPoint's (#273)
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>
2026-10-12 17:35:59 -04:00
Brandon ThomasandClaude Opus 5.5 4d13652f0f Pictures: duotone recolours, and layout/slide colour map overrides apply (#269)
Theme background pictures (Office 2007-era themes such as Treck) carry an
a:duotone recolour in phClr; DeckCraft read it but drew the picture
unchanged, so those backgrounds came out grey. The painter now resolves the
duotone's colours against the slide's colours and placeholder colour, and
the decoded-image cache applies them (luminance picks a colour between dark
and light) and keys on them.

Layouts and slides can replace the master's colour map
(p:clrMapOvr/a:overrideClrMapping, e.g. dark section layouts mapping bg1 to
dk1). They were read as the master's map; now Layout and Slide carry an
optional colour map, resolve::Ctx uses slide > layout > master, and the
writer keeps them.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:35:52 -04:00
Brandon ThomasandClaude Opus 5.5 235e34613b Text: superscript and subscript advance at their drawn size (#264)
Superscript and subscript glyphs were drawn at two thirds of the run's size but
advanced by the full size, so the letters spread apart ("s u p e r") and the
line ran wider than in PowerPoint. The cluster advance now uses the same scale
as the glyphs.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:35:28 -04:00
Brandon ThomasandClaude Opus 5.5 688c7ef592 Release 0.6.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:13 -04:00
Brandon ThomasandClaude Opus 5.5 a4540c1f0e Release workflow: pass the Azure signing secrets so Windows builds are signed (#181)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:13 -04:00
Brandon ThomasandClaude Opus 5.5 0ec0cc5e81 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:22 -04:00
Dylan M. Taylor be14248577 ci: refactor workflows to use storytold/craft-common-actions (#152)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:52:15 -04:00
Brandon ThomasandClaude Opus 5.5 a98df49b21 Release prep: 0.5.0 and Linux riscv64 release build (#102)
- release.yml: new linux-riscv64 job, ported from GridCraft. Cross-compiles on
  ubuntu-24.04 against ports.ubuntu.com riscv64 libs (incl. libasound2-dev:riscv64),
  packages a tar.gz and smoke-tests deckcraft-cli --version under QEMU. The release
  job needs it, so the draft and SHA256SUMS include it.
- packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR support.
- README Downloads: list the riscv64 tarball.
- Version 0.4.0 -> 0.5.0 (cargo xtask version set).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:51:07 -04:00
Brandon ThomasandClaude Opus 5.5 f44857624e Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#89)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:21 -04:00
d556ff98b5 Fields: date fields show today's date in their own format (#36)
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>
2026-10-10 06:25:32 -04:00
8fe168c07b Animation Painter: copy and apply animation sequences between shapes (#54)
- engine: add animation.painter (Alt+Shift+C) and animation.painterApply (Alt+Shift+V) supporting single-use and sticky modes
- tools: apply animation painter on canvas shape click
- edit: disarm animation painter on deselect (Escape)
- ui-egui: add Animation Painter button in Advanced Animation ribbon group
- tests: unit tests for single and sticky animation painting
- docs/parity: mark Animation Painter as done (breadth parity to 80%)
- contributors: add Johannesgauss to contributors/people.toml

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:24:23 -04:00
7f1e152870 Links: Insert Hyperlink with a bare caret inserts the address; the show follows links (#31) (#45)
Three causes, each making links look like they "don't save":

- With the caret at a word boundary (end of text, empty paragraph),
  insert.hyperlink formatted an empty range and changed nothing. It now
  inserts the address (or the slide's title, or `text`) as linked text, as
  PowerPoint does. Typing right after a link no longer extends it.
- The slide show ignored hyperlinks and shape actions. A click on one now
  follows it (ShowState::follow): slides, next/previous/first/last, Last
  Slide Viewed, End Show, and URLs; the pointer turns into a hand over links.
- The desktop app was built without eframe's `links` feature, so
  ctx.open_url did nothing natively (also the ribbon's app page link).
  Enabled as in DesignCraft, EffectCraft and PrintCraft.

Saving was fine: links round-trip through .deckcraft and .pptx.

generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:23:01 -04:00
3985d768e6 feat(pictures): animated GIFs play, with pause (media.gifPlay) (#39)
* 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>
2026-10-10 06:21:29 -04:00
alexanderalber e8c4c60884 Home: the title bar's Home button opens the start screen (#17) (#49)
It ran file.new, so each click silently added another blank presentation
and looked like it did nothing. Home is now a UI command (app.home) that
shows the start screen over the open presentations, with a "Back to …"
button; Escape or Home again goes back. Creating or opening a presentation
from it leaves it. While it's open, keys don't reach the hidden
presentation (only app shortcuts and Escape). ui.inspect reports `home`.

generated with Claude Code
2026-10-10 06:19:38 -04:00
alexanderalber a8c983ba6b Colour menus: clicking the hex field no longer closes the menu (#32) (#47)
egui::Popup::menu closes on any click inside the popup, so the "More
Colors… #" field of every colour drop-down closed the menu before a value
could be typed. The colour drop-downs now open through
widgets::color_popup (CloseOnClickOutside): picking a colour still closes
them (color_grid calls ui.close()), and the other buttons in them
(Gradient…, the Weight, Dashes and Arrows submenus of Shape Outline) close
them explicitly.

generated with Claude Code
2026-10-10 06:19:35 -04:00
alexanderalberandBrandon Thomas 788831834b Morph: a changed geometry morphs its outline (a circle turns into a star) (#27)
* 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>
2026-10-10 06:19:32 -04:00
ddbd71e297 Shapes: flipped shapes keep their text readable (#35)
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>
2026-10-10 06:18:46 -04:00
b1e6867c0b Add OmaStore manifest (#57)
* Add OmaStore manifest

* OmaStore manifest: the release tarball keeps the binary in bin/, not usr/bin/

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>
2026-10-10 06:17:49 -04:00
Dylan M. Taylor c2e7f7cc3c chore: bump github actions to fix Node 20 deprecation warnings (#52) 2026-10-10 06:17:45 -04:00
9c3840ffa4 Text: hyperlinks are drawn in the theme's hyperlink colour, underlined (#37)
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>
2026-10-10 06:17:41 -04:00
5c13f6ec72 MCP: the export tool lists PDF and its layout options (#65)
tools/list described export as png/jpeg/pptx/outline only, with no PDF parameters, although
file.export (which the tool runs) writes PDF slides, notes pages and handouts. The tool's
description now names PDF, format is an enum of the formats file.export writes, and the schema
lists the PDF options: layout, perPage, dpi, slides, includeHidden, textLayer and frame.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:17:37 -04:00
alexanderalber c543ef350e Slide show: transitions show the old slide instead of the new one twice (#33) (#46)
On a slide change the show kept a clone of the old slide's texture handle
as the transition's "old" picture, then rendered the new slide into that
same texture with set(). Old and new were the same image, so every
transition (except Morph, which renders into its own texture) blended the
new slide with itself and looked like an instant cut. The old texture is
now taken out of show.tex, so the new slide gets a texture of its own.

generated with Claude Code
2026-10-10 06:17:32 -04:00
alexanderalber 322d85074e App icon: scalable SVG for Linux desktops (#12) (#29)
The hicolor theme had only PNGs. The SVG writes out the shapes of
icon-source.deckcraft with the same coordinates, colours and corner radii;
rendered at 1024 px it matches deckcraft-1024.png up to anti-aliasing at
the curved edges. The Linux, Flatpak and FreeBSD packages copy hicolor/
as a whole, so they ship it without further changes.

generated with Claude Code
2026-10-10 06:17:28 -04:00
alexanderalber 08d57ca15c Morph test: sort the ids before dedup so it really checks uniqueness (#25)
dedup only removes consecutive duplicates, so two equal ids that were not
adjacent would have passed. Follow-up to the review note on #5.

generated with Claude Code
2026-10-10 06:17:24 -04:00
LloydNicholsonandClaude Opus 5.5 e17fcd5598 Issue #23: README lists the ALSA headers a Linux build needs (#24)
cpal pulls in alsa-sys, whose build script needs alsa.pc from the ALSA
development package; the other Linux -sys crates (wayland-sys with dlopen)
link nothing at build time, so this is the only extra package. CI already
installs libasound2-dev; the README now says so for people building locally.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:17:20 -04:00
Brandon ThomasandClaude Opus 5.5 84acb49895 Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:02 -04:00
4b09f35c1d feat(text, fonts): UAX #9 BiDi and Arabic complex script typography engine (#4)
* Sprint 1: bidi and Arabic typography engine

fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification;
shape_range shapes an item with the whole paragraph as HarfRust
pre/post context, explicit script/language/direction, glyphs kept in
visual order; per-grapheme-cluster fallback (slot face, theme script
font, OS cascade families, bundled craft-fonts face); GDEF ligature
carets.

text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization
by level, script, face and shaping style (paint-only changes no longer
split words), logical line breaking at cluster boundaries, per-line
L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and
bullets, justLow kashida; caret Affinity, logical/visual movement,
bidi hit testing and selection as the union of visual boxes.

model/pptx: theme a:font script fonts and algn="justLow" round-trip.
engine: format.rtl (Right-to-Left Text Direction), justifyLow align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: the bundled script face comes before installed fonts; no Microsoft family names

Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine
draws the same, then openly licensed or OS-native installed families, then any installed face.
Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tayeb <tayeb@192.168.11.179>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:48:26 -04:00
57545a99c8 Tables: rows grow to fit their text, cell text in the PDF text layer (#13) (#14)
* 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>
2026-10-09 10:40:13 -04:00
alexanderalberandBrandon Thomas fa9a94a1c0 Morph: shapes move, resize and blend; Words and Characters options morph text (#5)
* 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>
2026-10-09 10:32:41 -04:00
89d23ffa85 Default Windows startup to DirectX 12 (#16)
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168 and storytold/filmcraft#179.


Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:30:12 -04:00
Gert Pellin - GPEandBrandon Thomas 80e8a0fe53 Desktop: write log records to standard error and a rotating log file (#7)
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the crash guard's panic report, a failed
crash-recovery save, PowerPoint parts skipped on import, audio output
problems. A launch from a desktop menu has no terminal either, so there
was nothing to attach to a bug report.

apps/deckcraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/deckcraft.log (beside ui.json
  and prefs.json); each launch rotates the previous log to
  deckcraft.1.log and .2.log, so the log of a run that crashed survives
  the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for deckcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink; a poisoned lock is tolerated
- a record holds the lock for one write of an already formatted line

The cpal error callback runs on the realtime audio thread (on ALSA in a
loop while the device stays broken), so it no longer logs: it keeps the
first error without blocking, and the UI thread logs it when the stream
stops.

The logger is installed first in main (before the engine's panic hook)
and the file is opened after argument parsing (--version leaves no
file). The settings directory moves out of prefs_path into config_dir.
The start-up eprintln!s (sample, files that fail to open, control
server) now go through the logger. Documented in README.md (Getting
started > Logs).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:29:47 -04:00
MarekSpodBiedry ab6f511479 Start screen: theme tiles that wrap to a new row keep the left inset (#17) (#20) 2026-10-09 10:28:49 -04:00
b48870989c PPTX: recognise packages whose [Content_Types].xml is not in the first 4 KB (#19)
`sniff` searched only the leading 4096 bytes for the content-types part, but
OOXML writers may place that part anywhere in the archive. Recent PowerPoint
builds (16.x) store it as the last zip entry, so every deck saved by them was
rejected with "not a presentation DeckCraft can read" although it imports fine.
In one real folder 169 of 300 decks were affected.

Look the part up in the zip's central directory instead. The regression test
re-packs the sample deck with the part last behind 64 KB of padding and
checks that it is both recognised and imported.

Co-authored-by: Matthias Weber <webermatt@me.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 10:28:45 -04:00
alexanderalber b663be69b0 Slide show: a click after a long pause plays its step instead of skipping it (#11)
The step clock ran before the frame's input actions were applied. A click
that started a step (PlayStep) was timed against the old step_start (when the
slide was entered), so after waiting longer than the step's duration the step
finished in the same frame: everything appeared at once, without animation.
Input actions are now applied before the clock is read.

generated with Claude Code
2026-10-09 10:28:40 -04:00
Gert Pellin - GPE 34d5197370 README: install on Gentoo from the ::snakebyte overlay (#6)
* README: install on Gentoo from the ::snakebyte overlay

* Contributors: credit switch87 as Gert Pellin
2026-10-09 10:28:35 -04:00
Pedro Jose Soto Rivera 5fed177cbe Fix integer overflow on hostile H.264/HEVC parameter sets (#2)
Exp-Golomb values read with `read_ue()` can be as large as u32::MAX, so
`read_ue()? + N`, `log2_min + read_ue()?` and the cropping / conformance
window arithmetic overflowed: a panic in debug builds, and in release a
silent wrap (e.g. pic_width_in_mbs_minus1 = u32::MAX became a width of 0,
which passed the `<= 1024` check).

- bitstream: add `BitReader::read_ue_plus(n)`, an overflow-checked
  `ue(v) + n` that returns InvalidExpGolomb.
- h264/hevc: use it for every `read_ue()? + N`; saturate the log2 size
  sums (the existing range checks then reject them); validate the H.264
  crop in u64; make HEVC `crop_rect` saturating; validate the HEVC RPS
  delta range before casting to i32.
- Regression tests for each (they fail on the previous code).
2026-10-09 10:28:31 -04:00
Brandon Thomas baff6a2ff2 Merge pull request #8 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:21:03 -04:00
68aff057bf CI: no macOS on PRs or main; macOS is built and tested by releases (#22)
The org has 5 macOS slots in total (GitHub Team), shared by every repo's CI and
release builds, and PR CI kept them full, so release builds queued behind it.
macOS now runs only in release.yml; a release that fails on macOS is fixed and
rebuilt.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:51:28 -04:00
Xus BadiaandClaude Opus 5.5 21f1703994 macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the DeckCraft colour, with DeckCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~18 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:43:52 +02:00
Brandon ThomasandClaude Opus 5.5 d0e57d7e25 Bump version to 0.3.0 (from 0.2.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:02:34 -04:00
3e9bd4398a README Downloads: where to get it first, then builds with Windows before macOS (#10)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:50:48 -04:00
db67e0109f Release: Windows arm64, Flatpak aarch64, AppImage zsync updates, README Downloads (#9)
- Windows: add arm64 (aarch64-pc-windows-msvc) to the matrix. package.ps1, sign.ps1 and the
  WiX source already handled it; nothing in the dependency tree blocks the target (clippy -D
  warnings is clean for it).
- Flatpak: x86_64 and aarch64 bundles repackage the Linux job's tarball (PhotoCraft's
  approach: same binaries and embedded fonts as the AppImage/deb/rpm, no Rust build). The
  from-source manifest stays for Flathub; packaging-lint keeps the two in sync. Both now grant
  --socket=pulseaudio so slide-show audio/video plays in the sandbox.
- AppImage: embed gh-releases-zsync update information and publish the .zsync beside it.
- Jobs that sign nothing (version, linux, flatpak, freebsd, web) leave the release environment,
  so a branch dispatch is a dry run; the draft-release job now requires every build.
- README: Downloads section.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:43:53 -04:00
Brandon ThomasandClaude Opus 5.5 1839f41fce Bump version to 0.2.0 (from 0.1.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:13:34 -04:00
Brandon ThomasandClaude Opus 5.5 286ff74c8b Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:08:08 -04:00
Brandon ThomasandClaude Opus 5.5 e659ebcd27 Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:55:01 -04:00
Brandon ThomasandClaude Opus 5.5 a4bb8fe763 About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#3)
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)

Adds the craftrules standard (standards/contributors.md) to DeckCraft's About window:
tabs About · Contributors · Models. contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as
static tables. Each contributor line: @username, opt-in display/real name, PRs, commits,
lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical
ignores case and the leading @. The control channel's ui.dialog.open now honours its
documented `fields` param (e.g. About {"tab": "contributors"}).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:16:38 -04:00
Brandon ThomasandClaude Opus 5.5 9da767d5f7 ROADMAP: v0.1.0 released (signed, notarized)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:23:52 -04:00
Brandon ThomasandClaude Opus 5.5 efb03b90a8 Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#1)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:45:28 -04:00
127 changed files with 11031 additions and 1311 deletions
+7 -5
View File
@@ -23,12 +23,14 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15]
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
@@ -39,11 +41,11 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ci-${{ matrix.os }}
- name: cargo xtask ci
+4 -31
View File
@@ -23,34 +23,7 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+2 -36
View File
@@ -19,39 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/deckcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/deckcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.deckcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.deckcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.deckcraft.yml')); assert m['id'] == 'ai.storyteller.deckcraft' and m['command'] == 'deckcraft'"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
+23 -414
View File
@@ -1,14 +1,3 @@
# Release pipeline: every push to `release` builds installers for every platform and creates or
# updates the draft GitHub Release "DeckCraft v<version>" (tag v<version>). Recipe:
# craftrules release/playbook.md.
#
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
@@ -22,413 +11,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
DECKCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
jobs:
version:
name: version
# Only the release branch builds (a manual dispatch from any other ref skips everything). The
# branch itself is writable only by release-managers and org admins (ruleset "release").
if: github.ref == 'refs/heads/release'
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/common.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### DeckCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DECKCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$DECKCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10).
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DECKCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/deckcraft$|metainfo|applications'
ldd target/release/deckcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak-sources:
name: Flatpak crate sources
needs: version
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
- name: Vendor the crate sources (flatpak builds run offline)
run: |
python3 -m venv "$RUNNER_TEMP/venv"
"$RUNNER_TEMP/venv/bin/pip" install --quiet aiohttp tomlkit
curl -fsSL -o "$RUNNER_TEMP/flatpak-cargo-generator.py" \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
"$RUNNER_TEMP/venv/bin/python" "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o cargo-sources.json
- uses: actions/upload-artifact@v4
with:
name: flatpak-cargo-sources
path: cargo-sources.json
if-no-files-found: error
flatpak:
name: Linux Flatpak
needs: [version, flatpak-sources]
runs-on: ubuntu-latest
environment: release
# Best effort: Flathub builds from source itself (playbook §7); the bundle is a convenience.
continue-on-error: true
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Apply version override
if: inputs.version != ''
run: |
# No cargo in this container: the same edit `cargo xtask version set` makes.
sed -i "0,/^version = \".*\"/s//version = \"$DECKCRAFT_VERSION\"/" Cargo.toml
grep -m1 '^version' Cargo.toml
- uses: actions/download-artifact@v4
with:
name: flatpak-cargo-sources
path: packaging/linux/flatpak
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
manifest-path: packaging/linux/flatpak/ai.storyteller.deckcraft.yml
bundle: deckcraft-${{ needs.version.outputs.version }}-linux-x86_64.flatpak
upload-artifact: false
cache-key: flatpak-builder-${{ github.sha }}
- uses: actions/upload-artifact@v4
with:
name: release-flatpak
path: deckcraft-*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
runs-on: ubuntu-latest
environment: release
timeout-minutes: 240
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
VERSION_OVERRIDE: ${{ inputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: DECKCRAFT_VERSION DECKCRAFT_BUILD_DATE DECKCRAFT_BUILD_SHA VERSION_OVERRIDE
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
# copy stays small.
copyback: true
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "*"
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$DECKCRAFT_VERSION"; fi
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DECKCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
# The installers are required; the Flatpak bundle and FreeBSD tarball are attached when they
# built.
if: >-
!cancelled() && needs.version.result == 'success' && needs.macos.result == 'success' &&
needs.windows.result == 'success' && needs.linux.result == 'success' && needs.web.result == 'success'
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="DeckCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: deckcraft
app-title: DeckCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+34 -3
View File
@@ -3,7 +3,7 @@
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
PowerPoint parity — and superiority (speed, openness, agent control). It runs natively on macOS,
Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions:
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PrintCraft, EffectCraft, DesignCraft
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
(`../<app>`).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
@@ -158,5 +158,36 @@ to `main`.
## 7. Planning
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed)
tracks status, milestones, parity and estimates; update it whenever a milestone task lands.
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
docs follow craftrules `standards/progress-docs.md`:
| File | Holds |
|---|---|
| `ROADMAP.md` | Stage, headline numbers, dimensions, features, languages, progress log |
| `docs/target-app-parity.md` | The parity assessment against PowerPoint: percentages, hours, calibration |
| `docs/gaps.md` | Every known shortfall, ranked: the work list. Pick from the top |
| `docs/roadmap.md` | Current focus, the beta plan, milestones |
| `docs/parity-checklist.md` | Feature rows scored D/P/M; `cargo xtask parity` regenerates its summary |
| `docs/file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`, `animation-parity.md`, `slideshow-parity.md` | Per-dimension and per-feature detail |
| `docs/architecture.md` | How DeckCraft is built today |
When a task lands, re-score its checklist row, delete or update its `gaps.md` entry, add a line to
the `ROADMAP.md` progress log, and update each touched doc's status line and revision history.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+5
View File
@@ -18,12 +18,17 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/app-icon/deckcraft.icns`, `assets/app-icon/deckcraft.ico` | DeckCraft contributors | generated from the PNGs above by `packaging/icons.sh` (sips/iconutil, `cargo xtask ico`) | MIT OR Apache-2.0 | macOS and Windows app icons |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.deckcraft.svg` | DeckCraft contributors | the shapes of `assets/app-icon/icon-source.deckcraft` written out as SVG (same coordinates, colours and corner radii) | MIT OR Apache-2.0 | Scalable app icon for Linux desktops (hicolor theme) |
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
## Bundled through dependencies
+1 -1
View File
@@ -13,5 +13,5 @@ win over anything here.
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
`deckcraft-cli render`.
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- Keep `ROADMAP.md` and `plan/STATUS.md` current.
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
Generated
+289 -27
View File
@@ -644,6 +644,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -866,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"arboard",
"cpal",
@@ -885,7 +896,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -894,7 +905,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -903,14 +914,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -919,14 +930,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -948,7 +959,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -960,7 +971,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -971,7 +982,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"kurbo",
"serde",
@@ -979,7 +990,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -988,7 +999,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -997,7 +1008,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1005,11 +1016,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.1.0"
version = "0.6.1"
[[package]]
name = "deckcraft-mcp"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1019,7 +1030,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1037,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1048,15 +1059,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.1.0"
version = "0.6.1"
[[package]]
name = "deckcraft-opus"
version = "0.1.0"
version = "0.6.1"
[[package]]
name = "deckcraft-pdf"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-fonts",
"deckcraft-model",
@@ -1071,7 +1082,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1087,7 +1098,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -1097,25 +1108,28 @@ dependencies = [
"image",
"kurbo",
"log",
"skrifa 0.47.0",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
[[package]]
name = "deckcraft-text"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"chrono",
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-model",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
]
[[package]]
name = "deckcraft-ui-egui"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1132,11 +1146,12 @@ dependencies = [
"log",
"serde",
"serde_json",
"toml",
]
[[package]]
name = "deckcraft-vp9"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1145,7 +1160,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -1202,6 +1217,17 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "dlib"
version = "0.5.3"
@@ -1335,6 +1361,7 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
@@ -1633,6 +1660,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -2047,6 +2083,134 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -2348,6 +2512,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
@@ -3172,6 +3342,15 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -4196,6 +4375,16 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.2"
@@ -4383,6 +4572,24 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.27.0"
@@ -4699,6 +4906,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webbrowser"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -5356,6 +5579,12 @@ dependencies = [
"read-fonts 0.39.2",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -5427,7 +5656,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.6.1"
dependencies = [
"serde_json",
]
@@ -5602,6 +5831,39 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "3.0.0"
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.1.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -48,6 +48,7 @@ rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# text / rendering / formats
skrifa = "0.47"
+102 -6
View File
@@ -51,13 +51,14 @@
<a href="#getting-started">Getting started</a> ·
<a href="#agents-cli-and-mcp">Agents, CLI and MCP</a> ·
<a href="#roadmap">Roadmap</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
## What works today
DeckCraft is in early development, heading for its first alpha (see the [roadmap](ROADMAP.md)).
DeckCraft is in alpha, heading for beta (see the [roadmap](ROADMAP.md)).
Today it can:
- **Make decks**: slide masters and eleven layouts with placeholders that inherit position and
@@ -102,10 +103,43 @@ cd deckcraft
cargo run --release -p deckcraft-cli -- render --sample --all --scale 1 out/
```
On Linux, the build also needs the ALSA headers for slide-show sound: `alsa-lib-devel` on Fedora,
`libasound2-dev` on Debian and Ubuntu, `alsa-lib` on Arch. Without them it stops at `alsa-sys`.
Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone it next to this repo
(`../craft-fonts`) and local builds pick it up automatically; without it DeckCraft uses your system
fonts.
Each [GitHub release](https://github.com/storytold/deckcraft/releases) has ready-made builds, on Linux as
an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte
overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as
`app-office/deckcraft-bin` (not maintained by the DeckCraft team):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/deckcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/deckcraft
emerge --ask app-office/deckcraft-bin
```
### Logs
The desktop app writes its `log` records to standard error and to `logs/deckcraft.log` in the
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraft/logs/`, or
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DeckCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,deckcraft_pptx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`deckcraft*=debug`). |
The logger is `apps/deckcraft/src/logging.rs`; the realtime audio callback does not log. The web
app logs to the browser console instead.
## Agents, CLI and MCP
Every action in DeckCraft is a command with a stable id, so people, scripts and AI agents use the
@@ -120,10 +154,72 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
## Roadmap
DeckCraft covers about **79% of PowerPoint's features by breadth** (93% of the core ones) and is
roughly **80% of the way to a first alpha**. See [ROADMAP.md](ROADMAP.md) for the alpha checklist,
parity estimates and what's next, and [docs/parity.md](docs/parity.md) for the feature-by-feature
scorecard.
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
[docs/gaps.md](docs/gaps.md) for every known shortfall and
[docs/parity-checklist.md](docs/parity-checklist.md) for the feature-by-feature checklist.
## Downloads
**Download DeckCraft** from GitHub: the [latest release](https://github.com/storytold/deckcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/deckcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `deckcraft-<ver>-windows-x64.msi` | `deckcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `deckcraft-<ver>-windows-arm64.msi` | `deckcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `deckcraft-<ver>-windows-x86.msi` | `deckcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\deckcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `deckcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `deckcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `deckcraft-<ver>-linux-x86_64.AppImage` | `deckcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `deckcraft-<ver>-linux-x86_64.flatpak` | `deckcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `deckcraft-<ver>-linux-x86_64.deb` | `deckcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `deckcraft-<ver>-linux-x86_64.rpm` | `deckcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `deckcraft-<ver>-linux-x86_64.tar.gz` | `deckcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `deckcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
@@ -137,7 +233,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+128 -82
View File
@@ -1,100 +1,146 @@
# DeckCraft roadmap
DeckCraft aims at full PowerPoint parity — and to be better: faster, open (a documented zip+JSON
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
(macOS, Windows, Linux, BSD and the web).
(macOS, Windows, Linux, BSD and the web). This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) and the work list in
[docs/gaps.md](docs/gaps.md).
## Status (2026-10-07)
## Headline numbers
**Working today**
- Document model: presentations, slides, masters and 11 standard layouts with placeholder inheritance
(slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields,
embedded media, unlimited undo built on shared slide snapshots.
- Themes: 8 original themes with colour and font schemes, custom colours/fonts, backgrounds,
slide size presets.
- Shapes: ~150 preset geometries with adjustment handles, text boxes, pictures (with crop and
adjustments), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more),
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
window, rehearse timings, custom shows, Set Up Show; Reading View.
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
Slide Master; zoom; grayscale.
- Review: comments, accessibility checker, spelling.
- Audio and video: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264,
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
recognised and embedded but not yet playable.
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
palette, light/dark.
- Automation: ~200 commands, every one reachable from `deckcraft-cli`, the app's JSON control
channel and the MCP server (headless or connected to the running app).
- Web: `apps/deckcraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
secrets live in the `release` environment. Version: `cargo xtask version`.
**Next (in order):** first signed release run · PPTX corpus hardening against real-world decks ·
Animation Pane and presenter view polish · vector PDF artwork · Format Shape pane depth (3-D,
picture/texture options) · edit points · native macOS menu bar · print · Notes/Handout masters ·
equations · SVG pictures · WMA/WMV decoding.
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
## How close to an alpha (estimate, 2026-10-07)
An **alpha** here means: someone can install a signed build on macOS, Windows or Linux, make a real
deck from scratch or from a PowerPoint file, present it with transitions, animations and media,
save it back to `.pptx`/PDF without losing work, and hit no crashes on the common paths.
**DeckCraft is about 80% of the way to that alpha — roughly 30–40 wall-clock hours of a single
Claude Opus 5.5 agent** (about 12–18 hours with three agents in parallel).
| Alpha blocker | State | Estimate |
| | Value | Kind |
|---|---|---|
| First real release run: signing, notarization, installers verified on each OS | Workflows written and linted, never run | 6 h |
| PPTX fidelity on a corpus of real decks (import, round-trip, opens without repair) | Verified on generated decks only | 8 h |
| Presenter view and Animation Pane polish (timeline, reorder, preview) | Working, rough | 6 h |
| Soak and fuzz the editor (random command sequences, big decks, undo/redo) for crashes and slowness | Unit tests and guards only | 5 h |
| UI fidelity pass on the most-used ribbon groups and dialogs; first-run experience | Mostly there | 6 h |
| Mascot app icon (owner), README/site screenshots, user docs | Placeholder icon | 3 h |
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
| Hours to beta | **~120–210** Opus 5.5 agent hours (~40–70 h with 3–4 agents) | estimated, [docs/roadmap.md](docs/roadmap.md) |
| Hours to full parity | **~365–650** Opus 5.5 agent hours | estimated, [docs/target-app-parity.md](docs/target-app-parity.md) |
Already alpha-ready: editing, text, shapes (incl. connectors, freeform, merge), themes and
backgrounds, tables, charts, transitions, animations, slide show, audio/video playback, PDF export,
AutoRecover, CLI/MCP automation, web build.
### Readiness by audience
## How far from full parity (estimate, 2026-10-07)
| Audience | Ready | Opus 5.5 agent hours to ~95% | Work that dominates |
|---|---:|---|---|
| Full target (ready for real work) | ~55% | 340–600 (~60% parallelizes: ~120–210 h wall clock with 3–4 agents) | Feature depth across all 20 areas, legacy `.ppt`/`.odp` and video export, localization in 11+ languages, GPU raster and hardware decode, AI features |
| Mainstream practitioner | ~53% | 150–250 (~50% parallelizes: ~70–120 h with 3 agents) | `.pptx` exchange (real-deck corpus, round-trip losses, EMF/SVG, embedded fonts) 45–75 h; depth in charts, animations, pictures, text, presenting, print/vector PDF 70–110 h; interaction (menu bar, Format Shape pane, font faces, dialogs) 25–40 h; stability 10–20 h |
| Essentials user | ~62% | 50–90 (~40% parallelizes: ~30–55 h with 2–3 agents) | Opening received decks (corpus subset, EMF, SVG icons) 25–45 h; Windows launch fallback and Close prompt 6–12 h; font faces, Tab, paste, print, show navigation 12–20 h; start screen, menu bar, palette 6–10 h |
**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by
row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored
done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity
including depth is about 62%**.
Calibration is the same as for the full number (see [docs/target-app-parity.md](docs/target-app-parity.md#calibration-how-the-hours-were-derived)): a fix is 0.5–1 h, a checklist row to PowerPoint depth 2–4 h, a subsystem 6–35 h. The sets are nested, so essentials ≤ mainstream ≤ full. The full number is an additive weighted sum over the dimension table; only mainstream and essentials use multiplicative discounts.
**Remaining work to 100%: about 190 wall-clock hours of a single Claude Opus 5.5 agent** (±30%),
or roughly 65–90 hours with four agents in parallel on separate crates:
**Why alpha:** all six of PowerPoint's core workflows pass the alpha gate
([docs/roadmap.md](docs/roadmap.md#alpha-gate-powerpoints-core-workflows)): build, open and save
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
[docs/roadmap.md](docs/roadmap.md).
| Work | Estimate |
|---|---|
| Alpha blockers above | 35 h |
| Open P0/P1 rows (vector PDF, print, edit points, SVG, multi-monitor, draw table, SmartArt text pane, Notes/Handout masters…) | 45 h |
| Open P2 rows (equations, video export, 3-D, remove background, thesaurus, compare, record show…) | 40 h |
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 50 h |
| Performance (incremental rendering, GPU raster, streaming media decode) | 20 h |
## By dimension
Basis: in this session the engine, renderer, UI, show engine, PPTX, PDF, media and release
pipeline were built in about 30 wall-clock hours with up to three agents in parallel; recent
catalogue rows (connectors, freeform, merge shapes, AutoRecover, gradients) took 1–2 hours each.
| Dimension | Ready | Hours to full | Doc |
|---|---:|---|---|
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) |
| Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) |
| Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) |
| Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) |
| Ecosystem (templates; add-ins out of scope) | ~25% | 10–20 | [docs/gaps.md](docs/gaps.md#ecosystem) |
| AI features | ~10% | 20–40 | [docs/gaps.md](docs/gaps.md#ai-features) |
| Automation ✱ (CLI, control channel, MCP) | ahead | 0 | [docs/mcp.md](docs/mcp.md) |
## Features
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|---|---:|---:|---|---|
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
## Languages
English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
[docs/localization-parity.md](docs/localization-parity.md).
| Language | Code | UI strings | Status | Hours to full |
|---|---|---:|---|---|
| English | en | 100% | full | 0 |
| Simplified Chinese | zh-Hans | 0% | none | 6–10 |
| Spanish | es | 0% | none | 3–5 |
| Hindi | hi | 0% | none | 4–7 |
| Arabic | ar | 0% (slide text: bidi and shaping done) | none | 10–16 |
| French | fr | 0% | none | 3–5 |
| Portuguese | pt | 0% | none | 3–5 |
| Indonesian | id | 0% | none | 3–5 |
| Japanese | ja | 0% | none | 4–7 |
| German | de | 0% | none | 3–5 |
| Korean | ko | 0% | none | 4–7 |
| Vietnamese | vi | 0% | none | 3–5 |
Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
ships 26.
## Upcoming
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
image paste (#53), editor soak tests. 10–20 h.
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
36–64 h.
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
4. **Output:** vector PDF, printing. 10–16 h.
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
## Agents: CLI and MCP
Every command is reachable from `deckcraft-cli` (`run`, `describe`, `commands`, `app` for the running
window, `render`, `convert`), from MCP (`deckcraft-cli mcp`, optionally `--connect PORT`), and from the
app's JSON control channel (`deckcraft --control PORT`).
## Progress log
| Date | What landed |
|---|---|
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
| 2026-10-06 | Renamed SlideCraft to DeckCraft. PPTX import/export, PDF export, media playback with pure-Rust codecs, connectors with glue, freeform tools, merge shapes, AutoRecover, gradient editor, web build, release CI, parity catalogue |
| 2026-10-05 | M0: workspace, model, geometry, fonts, text, renderer, engine, MCP, CLI, PowerPoint-style egui UI, slide show engine |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
+1 -1
View File
@@ -14,7 +14,7 @@ workspace = true
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+72 -1
View File
@@ -1,12 +1,50 @@
//! cpal audio output for media playback: the device pulls mixed samples from the
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
use std::sync::{Arc, Mutex, PoisonError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use deckcraft_media::AudioOut;
/// The first stream error since it was last reported. cpal calls the error callback on its
/// realtime audio thread (on ALSA in a loop while the device stays broken), which must not log:
/// the callback only keeps the first error, and the UI thread logs it when the stream stops.
type ErrorSlot = Arc<Mutex<Option<String>>>;
/// Keep `e` unless an error is already kept. Never blocks: a busy slot drops the error.
fn keep_stream_error(slot: &ErrorSlot, e: &dyn std::fmt::Display) {
if let Ok(mut kept) = slot.try_lock()
&& kept.is_none()
{
*kept = Some(e.to_string());
}
}
/// The kept error, once (a poisoned lock is tolerated).
fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
slot.lock().unwrap_or_else(PoisonError::into_inner).take()
}
#[derive(Default)]
pub struct CpalOut {
stream: Option<cpal::Stream>,
error: ErrorSlot,
}
impl CpalOut {
/// Log the stream error the realtime callback kept (UI thread).
fn report(&self) {
if let Some(e) = take_stream_error(&self.error) {
log::warn!("audio stream error: {e}");
}
}
}
impl Drop for CpalOut {
fn drop(&mut self) {
self.stream = None;
self.report();
}
}
impl AudioOut for CpalOut {
@@ -17,7 +55,8 @@ impl AudioOut for CpalOut {
let channels = cfg.channels() as usize;
let rate = cfg.sample_rate().0;
let config: cpal::StreamConfig = cfg.clone().into();
let err = |e| log::warn!("audio stream error: {e}");
let slot = self.error.clone();
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
cpal::SampleFormat::I16 => {
@@ -45,5 +84,37 @@ impl AudioOut for CpalOut {
fn stop(&mut self) {
self.stream = None;
self.report();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_first_stream_error_is_kept_and_it_is_taken_once() {
let slot = ErrorSlot::default();
assert_eq!(take_stream_error(&slot), None);
keep_stream_error(&slot, &"device unplugged");
keep_stream_error(&slot, &"device unplugged again");
assert_eq!(take_stream_error(&slot).as_deref(), Some("device unplugged"));
assert_eq!(take_stream_error(&slot), None);
keep_stream_error(&slot, &"a later error");
assert_eq!(take_stream_error(&slot).as_deref(), Some("a later error"));
}
#[test]
fn a_poisoned_slot_still_reports() {
let slot = ErrorSlot::default();
keep_stream_error(&slot, &"lost");
let s = slot.clone();
let _ = std::thread::spawn(move || {
let _g = s.lock();
panic!("poison the slot");
})
.join();
assert!(slot.is_poisoned());
assert_eq!(take_stream_error(&slot).as_deref(), Some("lost"));
}
}
+2 -2
View File
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("deckcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("deckcraft: control server listening on 127.0.0.1:{port}");
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
}
}
}
+567
View File
@@ -0,0 +1,567 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
//! report, a failed crash-recovery save, PowerPoint parts that were skipped on import, audio output
//! problems) vanished. A launch from a desktop menu has no terminal, so the file is what a bug report
//! can attach: `<settings dir>/logs/deckcraft.log`, beside `ui.json` and `prefs.json`. Each start
//! moves the previous log to `deckcraft.1.log` (and that one to `.2`), so the log of a run that
//! crashed survives the next launch.
//!
//! Levels: `info` for DeckCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,deckcraft_pptx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`deckcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or written
//! leaves standard error as the only sink.
//!
//! Realtime audio: the cpal callbacks (`audio.rs`) do not log; a stream error is kept and logged
//! from the UI thread when the stream stops. A record takes the sink lock only for one `write_all`
//! of an already formatted line (standard error is written outside the lock).
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "deckcraft.log";
/// The log directory inside the settings directory.
pub const LOG_DIR: &str = "logs";
/// How many previous logs are kept (`deckcraft.1.log` … `deckcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,deckcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("deckcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
return;
}
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// No file will come: drop the kept lines and keep none from now on.
pub fn close(&mut self) {
self.file = None;
self.pending = Vec::new();
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink (and
// stop buffering, since no file will come).
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
match rotate(dir) {
Ok((path, file)) => {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
Err(e) => {
self.stderr_only();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
pub fn stderr_only(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Format before taking the lock, so the lock covers one write only.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("deckcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_deckcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("deckcraft"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft::audio"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft_engine::guard"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,deckcraft_pptx=trace,deckcraft_pptx::opc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Trace);
assert_eq!(f.level_for("deckcraft_pptx::opc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_ui_egui::canvas"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"deckcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,deckcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["deckcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,deckcraft=DEBUG").level_for("deckcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("deckcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("deckcraft.2.log")), "run 2");
assert!(!dir.join("deckcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line(
"2026-10-08T07:59:17.728Z",
log::Level::Warn,
"main",
"deckcraft_pptx::opc",
"pptx: skipping oversized part ppt/media/image1.png"
),
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "deckcraft", "DeckCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "deckcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("deckcraft: DeckCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("deckcraft_engine::recovery").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("deckcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "deckcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("deckcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "deckcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+35 -6
View File
@@ -5,12 +5,18 @@
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `deckcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/deckcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod audio;
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
@@ -32,8 +38,9 @@ impl eframe::App for App {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DeckCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DeckCraft"))
@@ -42,8 +49,11 @@ fn prefs_path() -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("deckcraft"))
};
base.map(|b| b.join("ui.json"))
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut SlideApp) {
@@ -125,6 +135,9 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -142,6 +155,19 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
Some(Err(e)) => log::warn!("no log file: {e}"),
None => {
logger.stderr_only();
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
}
}
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("DeckCraft")
@@ -164,6 +190,9 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
eframe::run_native(
"DeckCraft",
options,
@@ -185,11 +214,11 @@ fn main() -> eframe::Result {
app = app.with_control(rx);
}
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
eprintln!("deckcraft: sample: {e}");
log::error!("sample: {e}");
}
for f in files {
if let Err(e) = app.open_path(&f) {
eprintln!("deckcraft: {f}: {e}");
log::error!("{f}: {e}");
}
}
if show {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect x="16" y="16" width="480" height="480" rx="111.84" fill="#f26b1d"/><rect x="246" y="330" width="20" height="110" fill="#0b0b0c"/><rect x="176" y="430" width="160" height="18" fill="#0b0b0c"/><rect x="86" y="96" width="340" height="244" rx="14.64" fill="#efe9dc" stroke="#0b0b0c" stroke-width="12"/><rect x="126" y="136" width="170" height="20" fill="#0b0b0c"/><rect x="136" y="236" width="46" height="70" fill="#f26b1d"/><rect x="202" y="196" width="46" height="110" fill="#0b0b0c"/><rect x="268" y="166" width="46" height="140" fill="#f26b1d"/><circle cx="364" cy="178" r="28" fill="#0b0b0c"/></svg>

After

Width:  |  Height:  |  Size: 669 B

+161
View File
@@ -0,0 +1,161 @@
{
"schema": 1,
"repo": "storytold/deckcraft",
"branch": "main",
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
"head_date": "2026-10-09T14:48:26Z",
"commits": 41,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 5,
"commits": 28,
"lines_added": 124410,
"lines_deleted": 3748,
"binary_added": 47,
"binary_deleted": 12,
"first_commit": "2026-10-06T02:13:02Z",
"last_commit": "2026-10-09T12:51:28Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 56,
"lines_deleted": 56,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:34Z",
"last_commit": "2026-10-08T14:02:34Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 241,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "Pedroj-64",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 162,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:31Z",
"last_commit": "2026-10-09T14:28:31Z"
},
{
"login": "switch87",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 706,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:35Z",
"last_commit": "2026-10-09T14:29:47Z"
},
{
"login": "alexanderalber",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 718,
"lines_deleted": 53,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:40Z",
"last_commit": "2026-10-09T14:32:41Z"
},
{
"login": "wurstbrotdlx",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 65,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:45Z",
"last_commit": "2026-10-09T14:28:45Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 265,
"lines_deleted": 77,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:49Z",
"last_commit": "2026-10-09T14:40:13Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 67,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:30:12Z",
"last_commit": "2026-10-09T14:30:12Z"
},
{
"login": "hamzaelkachtaf4-star",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1902,
"lines_deleted": 282,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:48:26Z",
"last_commit": "2026-10-09T14:48:26Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 33,
"lines_added": 126890,
"lines_deleted": 4162
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 1,
"lines_added": 65,
"lines_deleted": 1
}
]
}
+39
View File
@@ -0,0 +1,39 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.DimaSmaha]
real_name = "Dmytro Smaha"
display_name = "Dima Smaha"
sync_github_name = false
[people.Johannesgauss]
sync_github_name = true
+4 -3
View File
@@ -3,7 +3,8 @@
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
//! per-paragraph) [`AnimState`] at any point of the show.
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
@@ -26,9 +27,9 @@ pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
pub use morph::{morph_pairs, morph_xfrm};
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{ShowAction, ShowState, show_order};
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
pub use transition::{Layer, Source, transition_layers};
+161 -2
View File
@@ -1,8 +1,10 @@
//! Morph: matching shapes between two slides and interpolating their boxes.
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use deckcraft_model::{Shape, ShapeId, Slide, Xfrm};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
use crate::clampf;
use crate::easing::smooth;
fn text_of(s: &Shape) -> String {
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
@@ -91,3 +93,160 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
flip_v: if late { b.flip_v } else { a.flip_v },
}
}
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
#[derive(Clone, Debug, Default)]
pub struct MorphFrame {
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
/// its placeholder, theme and style against that slide).
pub shapes: Vec<(Shape, bool)>,
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
pub opacity: Vec<(ShapeId, f64)>,
pub mix: f64,
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
/// characters instead of cross-fading (the renderer's `TextMorph`).
pub text: Vec<MorphText>,
/// Frame shapes whose outline turns from another geometry into their own (the renderer's
/// `PathMorph`).
pub paths: Vec<MorphPath>,
}
/// A frame shape whose outline morphs from `from` (the old shape's geometry) into its own.
#[derive(Clone, Debug, PartialEq)]
pub struct MorphPath {
pub id: ShapeId,
pub from: Geom,
/// The boxes of the old and the new shape on their own slides.
pub from_box: Xfrm,
pub to_box: Xfrm,
}
/// Two frame shapes whose texts morph by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MorphText {
/// Frame shape ids: from the old slide, from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// The boxes of the two shapes on their own slides.
pub from: Xfrm,
pub to: Xfrm,
pub chars: bool,
}
impl MorphFrame {
/// Opacity of shape `id` of the frame.
pub fn opacity_of(&self, id: ShapeId) -> f64 {
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
}
}
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
}
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
let ctx = ctx?;
match resolve::fill(ctx, s) {
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
_ => None,
}
}
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
match ctx {
Some(c) => Some(resolve::xfrm(c, s)),
None => s.xfrm,
}
}
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
fn content(s: &Shape) -> Shape {
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
}
/// Whether two shapes differ in their geometry only, and it is drawn as an outline (shapes,
/// pictures, connectors), so the outline can morph.
fn reshaped(a: &Shape, b: &Shape) -> bool {
let outlined = |s: &Shape| matches!(s.kind, ShapeKind::Shape | ShapeKind::Picture { .. } | ShapeKind::Connector { .. });
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
}
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
/// otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
let ca = Ctx::for_slide(pres, a);
let cb = Ctx::for_slide(pres, b);
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
let mut next = 1u32;
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
let mut f = MorphFrame { mix: s, ..Default::default() };
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
let mut text = Vec::new();
let mut paths = Vec::new();
let mut push = |mut sh: Shape, old: bool, op: f64| {
let id = ShapeId(next);
sh.id = id;
next = next.saturating_add(1);
if op < 1.0 {
f.opacity.push((id, op));
}
f.shapes.push((sh, old));
id
};
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
}
continue;
};
let (xa, xb) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb));
let x = match (xa, xb) {
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
(_, xb) => xb,
};
let reshape = reshaped(sa, sb);
if reshape || content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
}
let id = push(m, false, 1.0);
if reshape && let (Some(from_box), Some(to_box)) = (xa, xb) {
paths.push(MorphPath { id, from: sa.geom.clone(), from_box, to_box });
}
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
if by_text
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
{
text.push(MorphText { old, new, from, to, chars });
}
}
}
f.text = text;
f.paths = paths;
f
}
+48
View File
@@ -2,6 +2,7 @@
//! and automatic advance.
use deckcraft_model::Presentation;
use deckcraft_model::text::Action;
use crate::Timeline;
@@ -25,6 +26,32 @@ pub enum ShowAction {
Exit,
}
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
/// opened on a click.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
/// What a clicked link does in the show (`ShowState::follow`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LinkJump {
/// Navigation inside the show (already applied to the state).
Show(ShowAction),
/// Open this URL (`http`, `https` or `mailto`, see [`safe_url`]) outside the show.
Open(String),
/// End the show.
Exit,
/// Nothing to follow.
None,
}
/// Where a slide show is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct ShowState {
@@ -211,6 +238,27 @@ impl ShowState {
ShowAction::Slide(i)
}
/// Follow a clicked hyperlink or shape action. `last_viewed` is the slide shown before the
/// current one (Last Slide Viewed). Moves to slide targets; everything else is for the UI.
pub fn follow(&mut self, pres: &Presentation, action: &Action, last_viewed: Option<usize>) -> LinkJump {
let target = match action {
Action::Url { url } => return if safe_url(url) { LinkJump::Open(url.trim().to_string()) } else { LinkJump::None },
Action::EndShow => return LinkJump::Exit,
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
Action::NextSlide => self.next_index(pres),
Action::PreviousSlide => self.prev_index(pres),
Action::FirstSlide => show_order(pres).first().copied(),
Action::LastSlide => show_order(pres).last().copied(),
Action::LastViewed => last_viewed,
// Custom shows, programs and media actions aren't followed (yet).
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
};
match target {
Some(i) => LinkJump::Show(self.goto(pres, i)),
None => LinkJump::None,
}
}
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
/// show uses timings.
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
+139
View File
@@ -543,6 +543,117 @@ fn morph_xfrm_interpolates() {
assert!(m.rot.is_finite());
}
fn solid(s: Shape, r: u8) -> Shape {
Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s }
}
#[test]
fn morph_frame_moves_blends_and_fades() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") };
a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")];
let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") };
b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")];
let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4));
assert!((f.mix - 0.5).abs() < 1e-9);
// Gone fades out, Box moves halfway and blends red, New fades in, Later stays out.
let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect();
assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]);
let m = &f.shapes[1].0;
assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9);
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
assert_eq!(f.opacity_of(m.id), 1.0);
assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9);
// Ids are fresh and unique.
let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3);
assert!(ids.iter().all(|i| i.0 > 4));
// At the start only the old slide shows.
let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4));
assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New"));
assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0);
}
#[test]
fn morph_frame_cross_fades_changed_content() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }];
let f = morph_frame(&p, &a, &b, 0.25, &|_| false);
// The new text under the fading old one, both on the moving box.
assert_eq!(f.shapes.len(), 2);
assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false));
assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true));
let s = f.mix;
assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9);
}
#[test]
fn morph_frame_lists_texts_for_words_and_characters() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }];
for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] {
b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() });
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert_eq!(f.text.first().map(|m| m.chars), want, "{option}");
if let Some(m) = f.text.first() {
assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id));
assert_eq!(m.from, a.shapes[0].xfrm.unwrap());
}
}
// Same text: one shape moves, nothing to morph.
b.shapes[0].text = a.shapes[0].text.clone();
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
}
#[test]
fn morph_frame_morphs_the_outline_of_a_changed_geometry() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
let geo = |s: Shape, g: &str| Shape { geom: deckcraft_model::Geom::preset(g), ..s };
a.shapes = vec![solid(geo(shape(1, "Form"), "ellipse"), 0)];
b.shapes = vec![solid(Shape { xfrm: Some(Xfrm::new(300.0, 100.0, 100.0, 100.0)), ..geo(shape(1, "Form"), "star5") }, 200)];
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
// One shape: the new one, on the moving box, its colour blending and its outline listed to morph.
assert_eq!(f.shapes.len(), 1);
let (m, old) = &f.shapes[0];
assert!(!old && m.geom == b.shapes[0].geom);
assert_eq!(f.opacity_of(m.id), 1.0);
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
assert_eq!(f.paths.len(), 1);
assert_eq!(f.paths[0].id, m.id);
assert_eq!(f.paths[0].from, a.shapes[0].geom);
assert_eq!((f.paths[0].from_box, f.paths[0].to_box), (a.shapes[0].xfrm.unwrap(), b.shapes[0].xfrm.unwrap()));
// Other adjust values of the same preset morph too.
b.shapes[0].geom = deckcraft_model::Geom::Preset { name: "ellipse".into(), adj: vec![1.0] };
assert_eq!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.len(), 1);
// The same geometry only moves.
b.shapes[0].geom = a.shapes[0].geom.clone();
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
// Another text as well: the two cross-fade as before.
b.shapes[0].geom = deckcraft_model::Geom::preset("star5");
b.shapes[0].text = Some(TextBody::from_text("Star"));
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert!(f.paths.is_empty() && f.shapes.len() == 2);
// Tables and groups have no outline to morph.
let table = |g: &str| Shape { kind: deckcraft_model::ShapeKind::Table(Default::default()), ..geo(shape(1, "Form"), g) };
a.shapes = vec![table("rect")];
b.shapes = vec![table("ellipse")];
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
}
fn pres(n: usize) -> Presentation {
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
}
@@ -650,3 +761,31 @@ fn compose_and_sanitize() {
let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized();
assert!(finite(&bad));
}
#[test]
fn show_follows_links() {
use deckcraft_model::text::Action;
let mut p = pres(4);
Arc::make_mut(&mut p.slides[3]).hidden = true;
let mut s = ShowState::new(&p);
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.follow(&p, &Action::PreviousSlide, None), LinkJump::Show(ShowAction::Slide(1)));
assert_eq!(s.follow(&p, &Action::NextSlide, None), LinkJump::Show(ShowAction::Slide(2)));
// Last skips the hidden slide; a link to the hidden slide shows it.
assert_eq!(s.follow(&p, &Action::LastSlide, None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1003) }, None), LinkJump::Show(ShowAction::Slide(3)));
assert_eq!(s.follow(&p, &Action::FirstSlide, None), LinkJump::Show(ShowAction::Slide(0)));
assert_eq!(s.follow(&p, &Action::LastViewed, Some(3)), LinkJump::Show(ShowAction::Slide(3)));
assert_eq!(s.slide, 3);
assert_eq!(s.follow(&p, &Action::Url { url: "https://example.org".into() }, None), LinkJump::Open("https://example.org".into()));
for bad in
["file:///etc/passwd", "javascript:alert(1)", "ms-settings:", "smb://host/share", "C:\\Windows\\calc.exe", "../notes.txt", "https:", ""]
{
assert_eq!(s.follow(&p, &Action::Url { url: bad.into() }, None), LinkJump::None, "{bad} must not open");
}
assert_eq!(s.follow(&p, &Action::Url { url: " MAILTO:a@example.org".into() }, None), LinkJump::Open("MAILTO:a@example.org".into()));
assert_eq!(s.follow(&p, &Action::EndShow, None), LinkJump::Exit);
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(9) }, None), LinkJump::None, "a link to a deleted slide does nothing");
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
assert_eq!(s.slide, 3);
}
+1 -1
View File
@@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R {
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
/// frames with [`crate::morph_pairs`]).
/// frames with [`crate::morph_frame`]).
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
let t = clampf(t, 0.0, 1.0, 0.0);
let v = if t >= 1.0 {
+18
View File
@@ -134,6 +134,12 @@ impl<'a> BitReader<'a> {
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
}
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
/// hostile value near `u32::MAX`.
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
@@ -343,6 +349,18 @@ mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn read_ue_plus_rejects_overflow() {
let mut w = BitWriter::new();
w.write_ue(7);
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
assert!(r.read_ue_plus(1).is_err());
}
#[test]
fn reads_bits() {
let d = [0b1010_1100, 0b0101_0011];
+159
View File
@@ -49,6 +49,16 @@ pub fn specs() -> Vec<CommandSpec> {
options
),
cmd!("animation.clear", "Remove All Animations", [], None, "{index?: slide}", has_slide, clear),
cmd!(
noundo "animation.painter",
"Animation Painter",
["Animations", "Advanced Animation"],
Some("Alt+Shift+C"),
"{sticky?: bool, id?}",
has_selection,
painter_pick
),
cmd!("animation.painterApply", "Apply Animation", [], Some("Alt+Shift+V"), "{ids?}", has_selection, painter_apply),
cmd!(query "animation.list", "Animation Gallery", [], None, "{} → [{id, label, class, options}]", always, list),
cmd!(query "animation.get", "Animations on Slide", [], None, "{slide?} → [animation]", has_slide, get),
]
@@ -292,3 +302,152 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
let sl = st.doc.slides.get(i).ok_or_else(|| bad("animation.get", "no such slide"))?;
Ok(serde_json::to_value(&sl.animations).unwrap_or_default())
}
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.shapes.first().copied())
.ok_or_else(|| bad("animation.painter", "select an animated shape first"))?;
let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?;
let anims: Vec<Animation> = sl.animations.iter().filter(|a| a.shape == id).cloned().collect();
if anims.is_empty() {
return Err(bad("animation.painter", format!("shape {id} has no animations to copy")));
}
let sticky = bool_or(p, "sticky", false);
let count = anims.len();
s.anim_painter = Some((anims, sticky));
Ok(json!({"armed": true, "count": count}))
}
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
let Some((painted, sticky)) = s.anim_painter.clone() else {
return Err(bad("animation.painterApply", "pick animations with the Animation Painter first"));
};
if !sticky {
s.anim_painter = None;
}
let ids = targets(s, p)?;
if ids.is_empty() {
return Err(bad("animation.painterApply", "no target shapes selected"));
}
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.painterApply", "no slide"))?);
// As in PowerPoint, painting replaces the target's own animations (painting a shape onto
// itself doesn't double them). A paragraph effect keeps its paragraph only if the target
// has that paragraph; otherwise it animates the whole shape.
sl.animations.retain(|a| !ids.contains(&a.shape));
let mut added = 0;
for id in &ids {
let paras = deckcraft_model::find_shape(&sl.shapes, *id).and_then(|sh| sh.text.as_ref()).map_or(0, |t| t.paragraphs.len());
for a in &painted {
let mut cloned = a.clone();
cloned.shape = *id;
if cloned.paragraph.is_some_and(|i| i as usize >= paras) {
cloned.paragraph = None;
}
sl.animations.push(cloned);
added += 1;
}
}
Ok(json!({"applied": added}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn animation_painter_copies_effects_between_shapes() {
let mut s = Session::with_new();
// Insert two shapes
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
let id1 = s1.get("id").unwrap().as_u64().unwrap();
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
let id2 = s2.get("id").unwrap().as_u64().unwrap();
// Add 2 animations to shape 1: fade (entrance) and spin (emphasis)
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id1]})).unwrap();
s.execute("animation.add", &json!({"effect": "spin", "class": "emphasis", "ids": [id1]})).unwrap();
// Shape 2 initially has no animations
let anims_before = s.execute("animation.get", &json!({})).unwrap();
let arr_before = anims_before.as_array().unwrap();
assert_eq!(arr_before.len(), 2);
assert!(arr_before.iter().all(|a| a.get("shape").unwrap().as_u64().unwrap() == id1));
// Pick animations from shape 1
s.select(|_, sel| {
sel.shapes = vec![deckcraft_model::ShapeId(id1 as u32)];
})
.unwrap();
let res = s.execute("animation.painter", &json!({})).unwrap();
assert_eq!(res.get("armed").unwrap().as_bool(), Some(true));
assert_eq!(res.get("count").unwrap().as_u64(), Some(2));
assert!(s.anim_painter.is_some());
// Apply animations to shape 2
s.select(|_, sel| {
sel.shapes = vec![deckcraft_model::ShapeId(id2 as u32)];
})
.unwrap();
let applied = s.execute("animation.painterApply", &json!({})).unwrap();
assert_eq!(applied.get("applied").unwrap().as_u64(), Some(2));
// Single-click should disarm the painter
assert!(s.anim_painter.is_none());
// Painting again replaces shape 2's animations instead of adding to them; undo restores.
s.execute("animation.painter", &json!({"id": id1})).unwrap();
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
let count_on = |s: &mut Session, id: u64| {
let all = s.execute("animation.get", &json!({})).unwrap();
all.as_array().unwrap().iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id).count()
};
assert_eq!(count_on(&mut s, id2), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(count_on(&mut s, id2), 2, "undo restores the first painting");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(count_on(&mut s, id2), 0, "undo removes the painted animations");
s.execute("edit.redo", &json!({})).unwrap();
// Verify shape 2 now has the 2 animations cloned
let anims_after = s.execute("animation.get", &json!({})).unwrap();
let arr_after = anims_after.as_array().unwrap();
assert_eq!(arr_after.len(), 4);
let s2_anims: Vec<_> = arr_after.iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id2).collect();
assert_eq!(s2_anims.len(), 2);
assert_eq!(s2_anims[0].get("effect").unwrap().as_str(), Some("fade"));
assert_eq!(s2_anims[1].get("effect").unwrap().as_str(), Some("spin"));
// Picking from shape with no animations returns an error
s.execute("animation.clear", &json!({})).unwrap();
let err = s.execute("animation.painter", &json!({"id": id1}));
assert!(err.is_err());
}
#[test]
fn animation_painter_sticky_mode() {
let mut s = Session::with_new();
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
let id1 = s1.get("id").unwrap().as_u64().unwrap();
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
let id2 = s2.get("id").unwrap().as_u64().unwrap();
let s3 = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [300.0, 10.0, 100.0, 100.0]})).unwrap();
let id3 = s3.get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "zoom", "class": "entrance", "ids": [id1]})).unwrap();
// Pick with sticky = true
s.execute("animation.painter", &json!({"id": id1, "sticky": true})).unwrap();
assert!(s.anim_painter.is_some());
// Apply to shape 2 - painter remains armed
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
assert!(s.anim_painter.is_some());
// Apply to shape 3 - painter still armed
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
assert!(s.anim_painter.is_some());
}
}
+32 -12
View File
@@ -307,6 +307,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
hf.hide_on_title = v;
}
let hf = hf.clone();
let date_given = str_param(p, "dateText").is_some();
let all = bool_or(p, "all", true);
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
for i in list {
@@ -372,6 +373,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
sh.text = Some(TextBody::from_text(&hf.footer_text));
}
}
(Some(pos), true) if k == PhType::Date && date_given => {
if let Some(sh) = sl.shapes.get_mut(pos) {
set_date(sh, &hf.date_text);
}
}
_ => {}
}
}
@@ -381,19 +387,32 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Today's date as M/D/YYYY (no clock crate: computed from the system time).
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
/// `fixed`, at an automatic date field (one it already has keeps its format).
fn set_date(sh: &mut Shape, fixed: &str) {
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
let (text, kind) = match fixed {
"" if has_field => return,
"" => (today(), RunKind::Field { field: "datetime1".into() }),
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
_ => (fixed.to_string(), RunKind::Text),
};
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
}
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
/// field. Empty where there is no clock (wasm).
pub fn today() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let days = (secs / 86_400) as i64;
let (y, m, d) = civil(days);
format!("{m}/{d}/{y}")
}
#[cfg(target_arch = "wasm32")]
{
String::new()
}
date_field_text("datetime1")
}
/// What date field `kind` shows now (empty if unknown or there is no clock).
pub fn date_field_text(kind: &str) -> String {
use deckcraft_text::datetime::{DateTime, field_text};
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
}
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
@@ -464,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
},
);
+2
View File
@@ -336,6 +336,8 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
s.painter = None;
s.anim_painter = None;
s.select(|_, sel| {
if let Some(t) = sel.text.take() {
// Esc while editing text selects the shape.
+35 -1
View File
@@ -71,7 +71,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
text_outline
),
cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align),
cmd!(
"format.align",
"Align",
["Home", "Paragraph"],
None,
"{align: left|center|right|justify|justifyLow|distributed}",
has_text_or_shapes,
align
),
cmd!(
"format.rtl",
"Right-to-Left Text Direction",
["Home", "Paragraph"],
None,
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
has_text_or_shapes,
rtl
),
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
@@ -367,12 +384,29 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => Align::Left,
};
align_to(s, a)
}
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
/// at the start edge stays there.
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).rtl);
format_para(s, &move |para| {
if para.props.rtl.unwrap_or(false) != on {
para.props.align = match para.props.align {
None | Some(Align::Left) => Some(Align::Right),
Some(Align::Right) => Some(Align::Left),
other => other,
};
}
para.props.rtl = Some(on);
})
}
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
let on = on_param(p, Some(cur));
+19 -8
View File
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink),
cmd!(
"insert.hyperlink",
"Link",
["Insert", "Links"],
Some("Cmd+K"),
"{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}",
has_text_or_shapes,
hyperlink
),
cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd),
cmd!(
"chart.data",
@@ -556,7 +564,8 @@ fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "format").unwrap_or("datetime1").to_string();
field_run(s, &f, "")
// Saved with the date it shows now (the text kept where the date can't be computed).
field_run(s, &f, &super::design::date_field_text(&f))
}
fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
@@ -569,18 +578,20 @@ fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
let action = if let Some(u) = str_param(p, "url") {
Action::Url { url: u.to_string() }
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
let (action, display) = if let Some(u) = str_param(p, "url") {
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
} else if let Some(i) = usize_param(p, "slide") {
let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
Action::Slide { slide: id }
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
let title = slide.title();
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
} else {
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
};
let display = str_param(p, "text").map_or(display, str::to_string);
let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false };
if s.doc()?.selection.text.is_some() {
let l = link.clone();
return super::text::format_run(s, &move |rp| rp.link = Some(l.clone()));
return super::text::link_text(s, link, &display);
}
edit_shapes(s, p, "insert.hyperlink", |sh| {
sh.click = Some(link.clone());
+50
View File
@@ -33,6 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
poster_frame
),
cmd!(noundo "media.gifPlay", "Play/Pause GIF", [], None, "{id?, play?: bool (default: toggle)} → {id, playing}", has_doc, gif_play),
]
}
@@ -148,6 +149,34 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id, "ms": ms}))
}
/// Pause or play an animated GIF picture (`id` / `ids` / the selection, else the only one on the
/// slide). The UI host keeps its clock: a paused GIF holds its frame and resumes from it.
fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "media.gifPlay";
let st = s.doc()?;
let is_gif = |id: ShapeId| {
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { return false };
st.doc.media(fill.media).and_then(|m| crate::render::gif_frame(&m.data, &fill.adjust, 0.0)).is_some()
};
let ids = targets(s, p)?;
let id = match ids.iter().copied().find(|id| is_gif(*id)) {
Some(id) => id,
None if !ids.is_empty() && (p.get("id").is_some() || p.get("ids").is_some()) => return Err(bad(cmd, "not an animated GIF")),
None => match st.shapes().iter().map(|sh| sh.id).filter(|id| is_gif(*id)).collect::<Vec<_>>().as_slice() {
[id] => *id,
[] => return Err(bad(cmd, "no animated GIF on this slide")),
_ => return Err(bad(cmd, "select an animated GIF, or pass `id`")),
},
};
let play = bool_param(p, "play").unwrap_or_else(|| s.gif_paused.contains(&id));
if play {
s.gif_paused.remove(&id);
} else {
s.gif_paused.insert(id);
}
Ok(json!({"id": id, "playing": play}))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,6 +237,27 @@ mod tests {
assert!(s.execute("media.play", &json!({"id": sh["id"]})).is_err());
}
#[test]
fn gif_play_pauses_and_resumes_an_animated_gif() {
use image::{Delay, Frame, Rgba, RgbaImage};
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
let mut gif = Vec::new();
image::codecs::gif::GifEncoder::new(&mut gif).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
let mut s = Session::with_new();
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
assert!(s.execute("media.gifPlay", &json!({})).is_err(), "no GIF yet");
let r = s.execute("insert.picture", &json!({"name": "a.gif", "data": base64_encode(&gif)})).expect("insert");
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
let r = s.execute("media.gifPlay", &json!({})).expect("toggle");
assert_eq!(r["playing"], false);
assert!(s.gif_paused.contains(&id));
let r = s.execute("media.gifPlay", &json!({"id": id, "play": true})).expect("play");
assert_eq!(r["playing"], true);
assert!(s.gif_paused.is_empty());
let sh = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 10, 10]})).expect("shape");
assert!(s.execute("media.gifPlay", &json!({"id": sh["id"]})).is_err());
}
#[test]
fn wma_inserts_but_reports_unplayable() {
let mut s = Session::with_new();
+17 -4
View File
@@ -246,12 +246,25 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
s.doc()?.shape(id).cloned().ok_or_else(|| EngineError::Other(format!("no shape {id}")))
}
/// Effective box of a shape (placeholders inherit theirs).
/// Effective box of a shape (placeholders inherit theirs). A table's box is at least as tall as its
/// rows are drawn (rows grow to fit their text), so handles and hit tests match what is shown.
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
if let Some(x) = shape.xfrm {
return x;
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
if drawn.is_finite() && drawn > x.h {
x.h = drawn;
}
}
x
}
/// Row heights of table `t` as drawn: a stored height is a minimum, rows grow to fit their text.
pub fn table_drawn_heights(doc: &deckcraft_model::Presentation, sel: &crate::Selection, t: &deckcraft_model::Table) -> Vec<f64> {
match text::ctx_for(doc, sel) {
Some(ctx) => deckcraft_render::table_row_heights(&ctx, t, &deckcraft_text::NoFields),
None => t.rows.iter().map(|r| r.height).collect(),
}
ctx_xfrm(doc, sel, shape)
}
fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
+62 -17
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use deckcraft_model::edit::{self as ed, Pos};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{AutoFit, Run, RunKind, RunProps, TextBody};
use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody};
use deckcraft_model::{Presentation, ShapeId, ShapeKind};
use serde_json::{Value, json};
@@ -228,7 +228,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
let pos = edit_text(s, |body, t| {
let (a, b) = t.ordered();
let at = ed::delete(body, a, b);
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { None });
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) });
let c = ed::insert(body, at, &text, props);
t.anchor = c;
t.caret = c;
@@ -238,6 +238,20 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
Ok(json!({"caret": [pos.0, pos.1]}))
}
/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked
/// run, the props to type with are the run's without the link. `None` elsewhere.
fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option<RunProps> {
let mut props = ed::props_at(body, at);
props.link.as_ref()?;
let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count());
// props_at(c + 1) is the props of the character at c.
if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link {
return None;
}
props.link = None;
Some(props)
}
const AUTOCORRECT: &[(&str, &str)] = &[
("teh", "the"),
("adn", "and"),
@@ -535,21 +549,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let body = body_of(st, &t).cloned().unwrap_or_default();
let (mut a, mut b) = t.ordered();
if a == b {
let (wa, wb) = ed::word_at(&body, a);
let inside = wa.1 < a.1 && a.1 < wb.1;
if inside {
a = wa;
b = wb;
// Don't include the trailing space.
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
b.1 -= 1;
}
}
}
let (a, b) = format_range(st, &t);
let collapsed = a == b;
edit_text(s, |body, _| {
if collapsed {
@@ -591,6 +591,51 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
ok()
}
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
/// (without its trailing space). Empty when the caret is at a word boundary.
fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) {
let body = body_of(st, t).cloned().unwrap_or_default();
let (mut a, mut b) = t.ordered();
if a == b {
let (wa, wb) = ed::word_at(&body, a);
let inside = wa.1 < a.1 && a.1 < wb.1;
if inside {
a = wa;
b = wb;
// Don't include the trailing space.
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
b.1 -= 1;
}
}
}
(a, b)
}
/// Link the text being edited: the selection or the word at the caret, as `format_run` would.
/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the
/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does.
pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result<Value> {
let st = s.doc()?;
let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) };
let (a, b) = format_range(st, &t);
if a != b {
return format_run(s, &move |rp| rp.link = Some(link.clone()));
}
if display.is_empty() {
return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`"));
}
let pos = edit_text(s, |body, t| {
let mut props = ed::props_at(body, a);
props.link = Some(link);
let c = ed::insert(body, a, display, Some(props));
t.anchor = c;
t.caret = c;
c
})?;
Ok(json!({"caret": [pos.0, pos.1]}))
}
/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the
/// selected shapes).
pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result<Value> {
+6
View File
@@ -300,6 +300,8 @@ pub struct Session {
pub(crate) untitled: u32,
/// Format Painter: copied shape/text formatting and whether it stays on (double-click).
pub painter: Option<(cmd::format::Painted, bool)>,
/// Animation Painter: copied shape animations and whether it stays on (double-click).
pub anim_painter: Option<(Vec<deckcraft_model::anim::Animation>, bool)>,
/// Set as Default Shape: the look new shapes get.
pub default_look: Option<cmd::format::Painted>,
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
@@ -309,6 +311,8 @@ pub struct Session {
pub recovery_dir: Option<std::path::PathBuf>,
/// Media playback state, written by the UI host every frame (empty without a host).
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
pub gif_paused: std::collections::HashSet<ShapeId>,
}
impl Default for Session {
@@ -331,9 +335,11 @@ impl Session {
untitled: 0,
default_look: None,
painter: None,
anim_painter: None,
depth: 0,
recovery_dir: None,
media_status: Default::default(),
gif_paused: Default::default(),
}
}
/// A session with one new presentation open.
+177
View File
@@ -366,3 +366,180 @@ fn freeform_tools_and_command() {
g => panic!("{g:?}"),
}
}
#[test]
fn double_click_in_table_uses_grown_row_heights() {
// Rows stored 4 pt tall are drawn tall enough for their text, so 6 pt down is still row 0.
let mut s = session();
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
for row in 0..3 {
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
}
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0)));
}
#[test]
fn grown_table_frame_covers_its_drawn_rows() {
// Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows.
let mut s = session();
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
for row in 0..3 {
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
}
let st = s.doc().unwrap();
let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") };
let drawn: f64 = tools::table_rows(st, t).iter().sum();
assert!(drawn > 12.0, "rows should grow: {drawn}");
let x = cmd::xfrm_of(&st.doc, &st.selection, &sh);
assert!((x.h - drawn).abs() < 1e-6, "frame {} vs drawn {drawn}", x.h);
let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0);
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
}
#[test]
fn right_to_left_paragraphs() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
s.execute("format.rtl", &json!({})).unwrap();
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
assert_eq!(para(&s).rtl, Some(true));
// Left (the default) becomes right: the text stays at its start edge.
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("format.align", &json!({"align": "justifyLow"})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
// Toggling back keeps a justified alignment.
s.execute("format.rtl", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(false));
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
s.execute("format.rtl", &json!({"on": true})).unwrap();
s.execute("format.alignLeft", &json!({})).unwrap();
s.execute("format.rtl", &json!({"on": false})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(true));
}
#[test]
fn header_footer_fixed_date_is_the_slides_own_text() {
use deckcraft_model::text::RunKind;
let mut s = session();
let date = |s: &Session| {
let slide = s.doc().unwrap().current_slide().unwrap();
slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).and_then(|x| x.text.clone()).unwrap()
};
let kind = |s: &Session| date(s).paragraphs[0].runs[0].kind.clone();
// Automatic: a date field, saved with today's date.
s.execute("design.headerFooter", &json!({"date": true})).unwrap();
assert_eq!(kind(&s), RunKind::Field { field: "datetime1".into() });
assert_eq!(date(&s).text().matches('/').count(), 2, "M/D/YYYY");
// Fixed: plain text in the slide's own placeholder (other slides' fields aren't affected).
s.execute("design.headerFooter", &json!({"date": true, "dateText": "Launch day"})).unwrap();
assert_eq!((kind(&s), date(&s).text()), (RunKind::Text, "Launch day".to_string()));
// Back to automatic.
s.execute("design.headerFooter", &json!({"date": true, "dateText": ""})).unwrap();
assert!(matches!(kind(&s), RunKind::Field { .. }));
// A new date field is saved with the date it shows.
s.execute("text.edit", &json!({"id": s.doc().unwrap().current_slide().unwrap().shapes[0].id.0})).unwrap();
s.execute("text.insert", &json!({"text": "Updated "})).unwrap();
s.execute("insert.dateTime", &json!({"format": "datetime10"})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
let run = title.paragraphs[0].runs.iter().find(|r| matches!(r.kind, RunKind::Field { .. })).cloned().unwrap();
assert!(run.text.contains(':'), "H:mm {run:?}");
}
/// (text, linked?) of each run in the first paragraph of the current slide's title.
fn title_runs(s: &Session) -> Vec<(String, bool)> {
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
let p = &sh.text.as_ref().unwrap().paragraphs[0];
p.runs.iter().map(|r| (r.text.clone(), r.props.link.is_some())).collect()
}
#[test]
fn hyperlink_with_a_bare_caret_inserts_the_address() {
// #31: with the caret at the end of the text, Insert Hyperlink used to format an empty range
// and change nothing.
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "See "})).unwrap();
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
s.execute("text.insert", &json!({"text": " now"})).unwrap();
assert_eq!(
title_runs(&s),
vec![("See ".into(), false), ("https://example.org".into(), true), (" now".into(), false)],
"the address is inserted as a link, and typing after it doesn't extend it"
);
}
#[test]
fn hyperlink_display_text_and_targets() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("insert.hyperlink", &json!({"url": "mailto:a@b.org"})).unwrap();
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true)], "email links show the address without mailto:");
s.execute("text.insert", &json!({"text": " "})).unwrap();
s.execute("insert.hyperlink", &json!({"slide": 0, "text": "start"})).unwrap();
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true), (" ".into(), false), ("start".into(), true)]);
}
#[test]
fn hyperlink_on_a_word_or_shape_links_it() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
s.execute("text.move", &json!({"to": "left"})).unwrap();
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
assert_eq!(title_runs(&s), vec![("Hello ".into(), false), ("world".into(), true)], "a caret inside a word links the word");
s.execute("text.exit", &json!({})).unwrap();
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
}
#[test]
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
// Flips don't mirror text, so a click lands on the character drawn under it.
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 100], "text": "Hello flipped world"})).unwrap();
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
let pos = |s: &Session, x: f64, y: f64| {
let st = s.doc().unwrap();
tools::text_pos(st, st.shape(id).unwrap(), deckcraft_geom::Point::new(x, y))
};
let xs = [240.0, 300.0, 360.0];
let plain = xs.map(|x| pos(&s, x, 150.0));
assert!(plain[0].1 < plain[1].1 && plain[1].1 < plain[2].1, "{plain:?}");
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(xs.map(|x| pos(&s, x, 150.0)), plain);
// flipV turns the text 180° about the box centre (300, 150).
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
s.execute("shape.flip", &json!({"axis": "vertical"})).unwrap();
assert_eq!(xs.map(|x| pos(&s, 600.0 - x, 150.0)), plain);
}
#[test]
fn inserted_hyperlinks_look_like_links() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Read the docs"})).unwrap();
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
// Underlined (and hlink-coloured: same resolution as files) without touching the run's own props.
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], true);
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
let run = body.paragraphs[0].runs.iter().find(|r| r.props.link.is_some()).unwrap();
assert!(run.props.fill.is_none() && run.props.underline.is_none(), "{run:?}");
// Underline off is the run's own u="none", as in PowerPoint.
s.execute("format.underline", &json!({})).unwrap();
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
}
+34 -11
View File
@@ -434,6 +434,10 @@ impl Session {
if self.painter.is_some() && !toggle {
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
}
// Animation Painter applies on click.
if self.anim_painter.is_some() && !toggle {
let _ = self.execute("animation.painterApply", &json!({"ids": [id]}));
}
}
None => {
let add = mods.shift || mods.cmd;
@@ -789,7 +793,7 @@ impl Session {
let x = xfrm_of(&st.doc, &st.selection, &sh);
let local = x.affine().inverse() * p;
if let ShapeKind::Table(t) = &sh.kind {
let cell = deckcraft_render_cell(t, local);
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
}
Ok(Value::Null)
@@ -855,16 +859,28 @@ impl Session {
}
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
// Rows scale from their drawn heights: a stored height is only a minimum (often 0).
let drawn = self.active().and_then(|st| match &st.shape(id)?.kind {
ShapeKind::Table(t) => Some(table_rows(st, t)),
_ => None,
});
self.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
sh.xfrm = Some(x);
if let ShapeKind::Table(t) = &mut sh.kind {
// Tables resize their columns and rows proportionally.
let (tw, th) = (t.width().max(1e-6), t.height().max(1e-6));
let (kx, ky) = (x.w / tw, x.h / th);
let heights: Vec<f64> = match &drawn {
Some(d) if d.len() == t.rows.len() => d.clone(),
_ => t.rows.iter().map(|r| r.height).collect(),
};
let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::<f64>());
let kx = x.w / tw;
t.cols.iter_mut().for_each(|c| *c *= kx);
t.rows.iter_mut().for_each(|r| r.height *= ky);
if th > 1e-6 {
let ky = x.h / th;
t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky);
}
}
}
Ok(())
@@ -1246,10 +1262,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
/// Text position under slide point `p` in shape `sh`.
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
let x = xfrm_of(&st.doc, &st.selection, sh);
let local = x.affine().inverse() * p;
let t = TextSel { shape: sh.id, ..Default::default() };
match cmd::text::layout_for(st, &t) {
Some(l) => {
// The text's own transform: flips don't mirror text, vertical text is turned.
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
let h = l.hit(local);
(h.para, h.ch)
}
@@ -1257,16 +1275,21 @@ pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
}
}
/// Which table cell contains a table-local point.
fn deckcraft_render_cell(t: &deckcraft_model::Table, p: Point) -> (usize, usize) {
/// Row heights of table `t` as drawn: rows grow to fit their text.
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
cmd::table_drawn_heights(&st.doc, &st.selection, t)
}
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
let mut y = 0.0;
let mut row = t.rows.len().saturating_sub(1);
for (i, r) in t.rows.iter().enumerate() {
if p.y < y + r.height {
let mut row = heights.len().saturating_sub(1);
for (i, h) in heights.iter().enumerate() {
if p.y < y + h {
row = i;
break;
}
y += r.height;
y += h;
}
let mut x = 0.0;
let mut col = t.cols.len().saturating_sub(1);
+218
View File
@@ -0,0 +1,218 @@
//! Font fallback per grapheme cluster, and ligature caret positions.
//!
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
//! font, `a:font[@script="Arab"]`);
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
//! openly licensed or OS-native families found by name among the installed fonts (no platform
//! API is called); then any installed face covering the cluster;
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
//! requested face (drawn as .notdef).
use std::sync::Arc;
use skrifa::raw::TableProvider;
use crate::script::{ScriptTag, script_of};
use crate::{CRAFT_FONTS, FontDb, FontFace};
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
fn ignorable(c: char) -> bool {
c.is_whitespace()
|| c.is_control()
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
return false;
}
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
let default_emoji =
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
cluster.iter().any(|&c| default_emoji(c))
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
}
/// Installed families tried by name for a script after the bundled face, before scanning every
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
/// still used by the scan when nothing earlier covers the cluster.
fn os_families(script: ScriptTag) -> &'static [&'static str] {
match &script {
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
b"Thai" => &["Noto Sans Thai", "Thonburi"],
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
_ => &[],
}
}
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
let tag = std::str::from_utf8(&script).unwrap_or("");
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
v.dedup();
v
}
impl FontDb {
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
if is_emoji_cluster(cluster) {
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.color_face_for(key, cluster)
{
return f;
}
}
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
if cluster.contains(&'\u{FE0E}') {
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
let script = cluster.iter().find_map(|c| script_of(*c));
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
// prefer the bundled craft-fonts faces).
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
let bundled = bundled_families(s);
for fam in &bundled {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
for fam in os_families(s) {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
return f;
}
}
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
match self.fallback_for(key, exclude) {
Some(f) if covers_cluster(&f, cluster) => f,
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
_ => first(),
}
}
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
/// if no loaded face does.
#[cfg(not(target_arch = "wasm32"))]
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces
.iter()
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
.filter(|f| covers_cluster(f, cluster))
.collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
if self.system_fallback(key) { pick(self) } else { None }
}
}
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Some(font) = face.skrifa() else { return Vec::new() };
let Ok(gdef) = font.gdef() else { return Vec::new() };
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requested_face_wins_when_it_covers() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Spaces and joiners never force a fallback.
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Nothing to go on: the default face.
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
}
#[test]
fn arabic_cluster_falls_back_to_an_arabic_face() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
if covers_cluster(&f, &cluster) {
assert!(f.covers('\u{0628}'));
} else {
eprintln!("skipped: no installed or bundled face covers Arabic");
}
}
#[test]
fn ligature_carets_never_fail() {
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
for gid in [0, 1, 100, u32::MAX] {
let c = ligature_carets(&face, gid);
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
}
+95 -3
View File
@@ -79,6 +79,10 @@ pub struct FontFace {
/// values).
pub cap_height: f64,
pub x_height: f64,
/// Where the baseline sits in a line, as a share of the line's height from its top: the
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
/// the split PowerPoint uses (computed by `line_split`).
pub line_split: f64,
pub shaper: harfrust::ShaperData,
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
/// the normalised location and the shaper's view of it.
@@ -87,6 +91,8 @@ pub struct FontFace {
pub instance: Option<harfrust::ShaperInstance>,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// Colour glyph tables present (`is_color`), looked up on first use.
color: std::sync::OnceLock<bool>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@@ -154,6 +160,11 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
/// fonts have)?
pub fn is_color(&self) -> bool {
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@@ -254,6 +265,13 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Does face `index` of `data` carry colour glyph tables?
fn has_color_tables(data: &[u8], index: u32) -> bool {
use skrifa::raw::types::Tag;
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -436,6 +454,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
.clone()
}
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
/// baseline (observed).
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
use skrifa::raw::TableProvider;
let (a, d) = match f.os2() {
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
_ => (ascent, descent),
};
let total = a + d;
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
return 0.8;
}
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
@@ -467,6 +502,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
shaper,
coords,
location,
@@ -474,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
bytes,
index,
bmp: std::sync::OnceLock::new(),
color: std::sync::OnceLock::new(),
})
}
@@ -557,7 +594,7 @@ impl FontDb {
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
}
@@ -744,7 +781,7 @@ impl FontDb {
false
}
fn is_loaded(&self, family: &str) -> bool {
pub(crate) fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
@@ -842,7 +879,7 @@ impl FontDb {
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback(&self, c: char) -> bool {
pub(crate) fn system_fallback(&self, c: char) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
@@ -880,6 +917,61 @@ impl FontDb {
false
}
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
/// `key` if none is loaded yet. Misses are remembered.
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_color_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// Load an installed colour font covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_color_fallback(&self, c: char) -> bool {
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
if c.is_control() || c.is_whitespace() {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if !sys.enabled {
return false;
}
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
});
if hit && self.add_font(data) > 0 {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
+60 -83
View File
@@ -1,21 +1,25 @@
//! DeckCraft fonts: the font database (craft-fonts families, user and system fonts, and open
//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and
//! OpenType shaping.
//! OpenType shaping, script/slot classification and per-cluster fallback.
//!
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
//! and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! Shaping here is style-agnostic: [`shape`] and [`shape_range`] turn text in one face into glyph
//! ids, clusters and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! justification on top.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fallback;
mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
use harfrust::Tag;
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use script::{FontSlot, ScriptTag};
pub use shape::{ShapeParams, ShapedGlyph, shape_range};
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
@@ -53,17 +57,6 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
/// The default theme's font.
pub const DEFAULT_FAMILY: &str = "Inter";
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
pub fn feature(tag: &str) -> Option<Feature> {
let (on, t) = match tag.strip_prefix('-') {
@@ -83,6 +76,26 @@ pub fn is_rtl(c: char) -> bool {
matches!(unicode_bidi::bidi_class(c), R | AL)
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: each direction run is shaped with [`shape_range`] (right-to-left runs right to left,
/// with the whole string as context), then its clusters put back in text order (each cluster's
/// glyphs keep the shaper's order). Paragraph layout uses [`shape_range`] directly and keeps the
/// visual order.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_range(face, text, r, &ShapeParams { features, rtl, ..Default::default() }, &map);
if rtl {
// Visual (clusters descending) → logical; the sort is stable, so each cluster's
// glyphs keep the shaper's order.
g.sort_by_key(|x| x.cluster);
}
out.extend(g);
}
out
}
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
@@ -110,72 +123,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
runs
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
if !text.chars().any(is_rtl) {
return shape_dir(face, text, features, &map, false);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
for x in &mut g {
x.cluster += r.start;
}
if rtl {
// Visual (clusters descending) → logical, cluster by cluster.
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
for x in g {
match groups.last_mut() {
Some(last) if last[0].cluster == x.cluster => last.push(x),
_ => groups.push(vec![x]),
}
}
groups.sort_by_key(|grp| grp[0].cluster);
g = groups.into_iter().flatten().collect();
}
out.extend(g);
}
out
}
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
}
out
}
/// Glyph id of the first of `chars` the face has (0 = .notdef).
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
@@ -189,6 +136,25 @@ mod tests {
FontDb::global().face(DEFAULT_FAMILY, "Regular")
}
#[test]
fn emoji_clusters_are_recognised() {
let c = |s: &str| s.chars().collect::<Vec<char>>();
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}
#[test]
fn colour_fonts_are_recognised() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
assert!(!fontdb::last_resort_face().is_color());
}
#[test]
fn last_resort_face_parses() {
let f = fontdb::last_resort_face();
@@ -196,6 +162,17 @@ mod tests {
assert!(f.upem > 0.0);
}
#[test]
fn line_split_follows_the_windows_ascent_and_descent() {
use skrifa::raw::TableProvider;
let f = fontdb::last_resort_face();
let font = skrifa::FontRef::new(f.data()).unwrap();
let os2 = font.os2().unwrap();
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
assert!(f.line_split > 0.5 && f.line_split < 1.0);
}
#[test]
fn some_face_always_resolves() {
let db = FontDb::with_font_dirs(Vec::new());
+246
View File
@@ -0,0 +1,246 @@
//! Script and font-slot classification.
//!
//! DrawingML gives every run three typefaces: `a:latin`, `a:ea` (East Asian) and `a:cs` (complex
//! script: Arabic, Hebrew, Thai, Indic…). Which one a character uses follows its Unicode script
//! (UAX #24). Characters with no script of their own (Common and Inherited: spaces, digits,
//! punctuation, combining marks) take the script and slot of the text around them, so a space or
//! a parenthesis between two Arabic words is shaped with them.
/// An ISO 15924 script tag, e.g. `*b"Arab"`.
pub type ScriptTag = [u8; 4];
pub const ARABIC: ScriptTag = *b"Arab";
pub const HEBREW: ScriptTag = *b"Hebr";
pub const LATIN: ScriptTag = *b"Latn";
/// The DrawingML typeface slot a character is drawn with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FontSlot {
/// `a:latin`.
#[default]
Latin,
/// `a:ea`.
EastAsian,
/// `a:cs`.
ComplexScript,
}
/// Inclusive code point ranges and their script (a subset of `Scripts.txt` covering the scripts
/// the slot decision and shaping need; everything else is Latin-slot or Common).
const RANGES: &[(u32, u32, ScriptTag)] = &[
(0x0041, 0x005A, *b"Latn"),
(0x0061, 0x007A, *b"Latn"),
(0x00AA, 0x00AA, *b"Latn"),
(0x00BA, 0x00BA, *b"Latn"),
(0x00C0, 0x00D6, *b"Latn"),
(0x00D8, 0x00F6, *b"Latn"),
(0x00F8, 0x024F, *b"Latn"),
(0x0370, 0x03FF, *b"Grek"),
(0x0400, 0x052F, *b"Cyrl"),
(0x0530, 0x058F, *b"Armn"),
(0x0591, 0x05F4, *b"Hebr"),
// Arabic block, minus the Common/Inherited code points handled in `script_of`.
(0x0600, 0x06FF, *b"Arab"),
(0x0700, 0x074F, *b"Syrc"),
(0x0750, 0x077F, *b"Arab"),
(0x0780, 0x07BF, *b"Thaa"),
(0x07C0, 0x07FF, *b"Nkoo"),
(0x0870, 0x08FF, *b"Arab"),
(0x0900, 0x097F, *b"Deva"),
(0x0980, 0x09FF, *b"Beng"),
(0x0A00, 0x0A7F, *b"Guru"),
(0x0A80, 0x0AFF, *b"Gujr"),
(0x0B00, 0x0B7F, *b"Orya"),
(0x0B80, 0x0BFF, *b"Taml"),
(0x0C00, 0x0C7F, *b"Telu"),
(0x0C80, 0x0CFF, *b"Knda"),
(0x0D00, 0x0D7F, *b"Mlym"),
(0x0D80, 0x0DFF, *b"Sinh"),
(0x0E00, 0x0E7F, *b"Thai"),
(0x0E80, 0x0EFF, *b"Laoo"),
(0x0F00, 0x0FFF, *b"Tibt"),
(0x1000, 0x109F, *b"Mymr"),
(0x10A0, 0x10FF, *b"Geor"),
(0x1100, 0x11FF, *b"Hang"),
(0x1200, 0x139F, *b"Ethi"),
(0x1780, 0x17FF, *b"Khmr"),
(0x1E00, 0x1EFF, *b"Latn"),
(0x1F00, 0x1FFF, *b"Grek"),
(0x2C60, 0x2C7F, *b"Latn"),
(0x2E80, 0x2FDF, *b"Hani"),
(0x3040, 0x309F, *b"Hira"),
(0x30A0, 0x30FF, *b"Kana"),
(0x3100, 0x312F, *b"Bopo"),
(0x3130, 0x318F, *b"Hang"),
(0x31F0, 0x31FF, *b"Kana"),
(0x3400, 0x4DBF, *b"Hani"),
(0x4E00, 0x9FFF, *b"Hani"),
(0xA720, 0xA7FF, *b"Latn"),
(0xAC00, 0xD7AF, *b"Hang"),
(0xF900, 0xFAFF, *b"Hani"),
(0xFB00, 0xFB06, *b"Latn"),
(0xFB1D, 0xFB4F, *b"Hebr"),
(0xFB50, 0xFDFF, *b"Arab"),
(0xFE70, 0xFEFF, *b"Arab"),
(0xFF21, 0xFF3A, *b"Latn"),
(0xFF41, 0xFF5A, *b"Latn"),
(0xFF66, 0xFF9F, *b"Kana"),
(0x10E60, 0x10E7F, *b"Arab"),
(0x1EE00, 0x1EEFF, *b"Arab"),
(0x20000, 0x323AF, *b"Hani"),
];
/// Code points inside the Arabic blocks whose script is Common or Inherited: the Arabic comma,
/// semicolon and question mark, tatweel, the harakat (combining vowel marks) and the BOM-like
/// U+FEFF. They take the script of the text around them.
fn arabic_common(cp: u32) -> bool {
matches!(cp, 0x060C | 0x061B | 0x061F | 0x0640 | 0x064B..=0x0655 | 0x0670 | 0xFEFF | 0xFD3E | 0xFD3F)
}
/// The script of `c`, or `None` for Common and Inherited characters (spaces, digits,
/// punctuation, combining marks), which take the script of their neighbours.
pub fn script_of(c: char) -> Option<ScriptTag> {
let cp = c as u32;
if arabic_common(cp) {
return None;
}
// Ranges are sorted and disjoint: binary search on the start.
let i = RANGES.partition_point(|(lo, _, _)| *lo <= cp);
let (lo, hi, tag) = RANGES.get(i.checked_sub(1)?)?;
(cp >= *lo && cp <= *hi).then_some(*tag)
}
/// The slot a script is drawn with.
pub fn slot_of_script(s: ScriptTag) -> FontSlot {
match &s {
b"Hani" | b"Hira" | b"Kana" | b"Hang" | b"Bopo" => FontSlot::EastAsian,
b"Arab" | b"Hebr" | b"Syrc" | b"Thaa" | b"Nkoo" | b"Deva" | b"Beng" | b"Guru" | b"Gujr" | b"Orya" | b"Taml" | b"Telu" | b"Knda" | b"Mlym"
| b"Sinh" | b"Thai" | b"Laoo" | b"Tibt" | b"Mymr" | b"Khmr" => FontSlot::ComplexScript,
_ => FontSlot::Latin,
}
}
/// The slot of `c` on its own: `None` for characters that take their neighbours' slot. CJK
/// punctuation and full-width forms are East Asian although their script is Common.
pub fn slot_of(c: char) -> Option<FontSlot> {
if let Some(s) = script_of(c) {
return Some(slot_of_script(s));
}
let cp = c as u32;
if (0x3000..=0x303F).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) || (0x3200..=0x33FF).contains(&cp) {
return Some(FontSlot::EastAsian);
}
None
}
/// A default BCP 47 language for a script (shaping picks `locl` forms by language).
pub fn default_language(s: ScriptTag) -> Option<&'static str> {
Some(match &s {
b"Arab" => "ar",
b"Hebr" => "he",
b"Syrc" => "syr",
b"Thaa" => "dv",
b"Thai" => "th",
b"Deva" => "hi",
_ => return None,
})
}
/// Resolve every character's script and slot, giving Common/Inherited characters the value of the
/// preceding character with one (or, at the start, the following one; Latin when there is none).
pub fn resolve(chars: &[char]) -> Vec<(Option<ScriptTag>, FontSlot)> {
let own: Vec<(Option<ScriptTag>, Option<FontSlot>)> = chars.iter().map(|c| (script_of(*c), slot_of(*c))).collect();
let first = own.iter().find_map(|(s, sl)| sl.map(|sl| (*s, sl))).unwrap_or((None, FontSlot::Latin));
let mut prev = first;
own.into_iter()
.map(|(s, sl)| {
if let Some(sl) = sl {
prev = (s, sl);
}
prev
})
.collect()
}
/// Arabic letters that join to the following letter (joining type D, dual-joining): a kashida
/// (tatweel) may be inserted after them. Right-joining (alef, dal, reh, waw…) and non-joining
/// (hamza) letters are excluded.
pub fn joins_following(c: char) -> bool {
let cp = c as u32;
let letter = matches!(cp, 0x0620..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9);
letter
&& !matches!(
cp,
0x0621..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699 | 0x06C0
| 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3 | 0x0759..=0x075B | 0x076B | 0x076C | 0x0771 | 0x0773 | 0x0774
| 0x0778 | 0x0779 | 0x08AA..=0x08AC | 0x08AE | 0x08B1 | 0x08B2 | 0x08B9
)
}
/// An Arabic letter that joins to the preceding letter (joining type D or R).
pub fn joins_preceding(c: char) -> bool {
let cp = c as u32;
matches!(cp, 0x0622..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9) && cp != 0x0621
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scripts_and_slots() {
assert_eq!(script_of('س'), Some(ARABIC));
assert_eq!(script_of('ש'), Some(HEBREW));
assert_eq!(script_of('R'), Some(LATIN));
assert_eq!(script_of('2'), None);
assert_eq!(script_of('('), None);
// Harakat and tatweel are Inherited/Common.
assert_eq!(script_of('\u{064E}'), None);
assert_eq!(script_of('\u{0640}'), None);
assert_eq!(slot_of('س'), Some(FontSlot::ComplexScript));
assert_eq!(slot_of('日'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('、'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('a'), Some(FontSlot::Latin));
assert_eq!(slot_of(' '), None);
assert_eq!(script_of('\u{10FFFF}'), None);
assert_eq!(script_of('\0'), None);
}
#[test]
fn neutrals_take_the_surrounding_slot() {
let chars: Vec<char> = "سنة 2024 (Retrait) فقط".chars().collect();
let r = resolve(&chars);
// "سنة 2024 (" follows Arabic: complex script.
for (i, c) in chars.iter().enumerate().take(10) {
assert_eq!(r[i].1, FontSlot::ComplexScript, "{c:?}");
}
// "Retrait)" is Latin; the space after ")" stays Latin; "فقط" is complex script.
assert_eq!(r[10].1, FontSlot::Latin);
assert_eq!(r[17].1, FontSlot::Latin);
assert_eq!(r[19], (Some(ARABIC), FontSlot::ComplexScript));
// Leading neutrals take the first strong character's slot.
let r = resolve(&['(', 'س']);
assert_eq!(r[0].1, FontSlot::ComplexScript);
assert!(resolve(&[]).is_empty());
assert_eq!(resolve(&['1'])[0], (None, FontSlot::Latin));
}
#[test]
fn joining_classes() {
assert!(joins_following('س'));
assert!(joins_following('ن'));
assert!(!joins_following('ا'));
assert!(!joins_following('د'));
assert!(!joins_following('ء'));
assert!(joins_preceding('ا'));
assert!(!joins_preceding('ء'));
assert!(!joins_following('a'));
}
#[test]
fn ranges_are_sorted_and_disjoint() {
for w in RANGES.windows(2) {
assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! OpenType shaping with HarfRust.
//!
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
use std::ops::Range;
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
use crate::FontFace;
use crate::script::ScriptTag;
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// How to shape an item.
#[derive(Clone, Copy, Debug, Default)]
pub struct ShapeParams<'a> {
pub features: &'a [Feature],
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
pub rtl: bool,
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
pub script: Option<ScriptTag>,
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
pub language: Option<&'a str>,
}
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in text.char_indices() {
buf.add(map(c), (range.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
}
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
// No shaper for this face: one glyph per character from the cmap, in visual order.
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if params.rtl {
out.reverse();
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DEFAULT_FAMILY, FontDb};
#[test]
fn rtl_items_come_in_visual_order_and_mirror() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let text = "(ab)";
let p = ShapeParams { rtl: true, ..Default::default() };
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
// Visual order: last cluster first.
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
if face.covers('(') && face.covers(')') {
assert_eq!(g[3].gid, face.glyph_for(')'));
assert_eq!(g[0].gid, face.glyph_for('('));
}
}
#[test]
fn clusters_point_into_the_whole_paragraph() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let para = "Hello world";
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
assert_eq!(g.first().map(|g| g.cluster), Some(6));
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
// Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
}
}
+1
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod morph;
pub mod preset;
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
+555
View File
@@ -0,0 +1,555 @@
//! Morph between two outlines: one shape turns into another (a circle into a star).
//!
//! The approach is manim's: both outlines get the same number of contours and each contour pair the
//! same number of cubic curves, then every control point moves in a straight line. On top of that,
//! closed contours are turned and reversed so that their points travel as short a way as possible
//! (a triangle does not twist on its way into a circle), and extra points go to the longest curves.
use kurbo::{BezPath, PathEl, Point, Rect, Vec2};
use crate::preset::{FillMode, Geometry, SubPath};
/// A contour as cubic curves: the start anchor, then three points per curve (handle, handle,
/// anchor).
#[derive(Clone, Debug, PartialEq)]
pub struct Contour {
pub closed: bool,
pub pts: Vec<Point>,
}
impl Contour {
pub fn curves(&self) -> usize {
self.pts.len().saturating_sub(1) / 3
}
}
fn line_to(p: &mut Vec<Point>, to: Point) {
let Some(&from) = p.last() else { return };
p.extend([from.lerp(to, 1.0 / 3.0), from.lerp(to, 2.0 / 3.0), to]);
}
fn close(c: &mut Contour) {
if let (Some(&first), Some(&last)) = (c.pts.first(), c.pts.last())
&& first != last
{
line_to(&mut c.pts, first);
}
c.closed = true;
}
fn pen(cur: &mut Option<Contour>, start: Point) -> &mut Contour {
cur.get_or_insert_with(|| Contour { closed: false, pts: vec![start] })
}
fn keep(c: Contour, out: &mut Vec<Contour>) {
if c.curves() > 0 && c.pts.iter().all(|p| p.is_finite()) {
out.push(c);
}
}
/// The contours of a path, every segment as a cubic curve. Contours without a curve or with
/// non-finite points are left out.
pub fn contours(path: &BezPath) -> Vec<Contour> {
let mut out = Vec::new();
let mut cur: Option<Contour> = None;
let mut start = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(c) = cur.take() {
keep(c, &mut out);
}
start = p;
cur = Some(Contour { closed: false, pts: vec![p] });
}
PathEl::LineTo(p) => line_to(&mut pen(&mut cur, start).pts, p),
PathEl::QuadTo(q, p) => {
let c = pen(&mut cur, start);
if let Some(&a) = c.pts.last() {
c.pts.extend([a.lerp(q, 2.0 / 3.0), p.lerp(q, 2.0 / 3.0), p]);
}
}
PathEl::CurveTo(a, b, p) => pen(&mut cur, start).pts.extend([a, b, p]),
PathEl::ClosePath => {
if let Some(mut c) = cur.take()
&& c.curves() > 0
{
close(&mut c);
keep(c, &mut out);
}
}
}
}
if let Some(c) = cur {
keep(c, &mut out);
}
out
}
/// Contours back to a path.
pub fn to_path(cs: &[Contour]) -> BezPath {
let mut bp = BezPath::new();
for c in cs {
let Some(&s) = c.pts.first() else { continue };
bp.move_to(s);
for [a, b, p] in c.pts.get(1..).unwrap_or(&[]).as_chunks::<3>().0 {
bp.curve_to(*a, *b, *p);
}
if c.closed {
bp.close_path();
}
}
bp
}
// --- alignment ---------------------------------------------------------------------------------
/// Rough curve length: mean of chord and control polygon.
fn curve_length(c: &[Point]) -> f64 {
let [p0, p1, p2, p3] = c else { return 0.0 };
(p0.distance(*p3) + p0.distance(*p1) + p1.distance(*p2) + p2.distance(*p3)) / 2.0
}
/// Appends curve `c` (four points) split into `n` pieces of equal parameter length (de Casteljau),
/// without its start anchor.
fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
let [a, b, d, e] = c else { return };
let (mut p0, mut p1, mut p2, p3) = (*a, *b, *d, *e);
for j in (2..=n).rev() {
let t = 1.0 / j as f64;
let (ab, bc, cd) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t));
let (abc, bcd) = (ab.lerp(bc, t), bc.lerp(cd, t));
let m = abc.lerp(bcd, t);
out.extend([ab, abc, m]);
(p0, p1, p2) = (m, bcd, cd);
}
out.extend([p1, p2, p3]);
}
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
/// splits go to the longest pieces, so a long side gets more points than a short one.
fn refine(c: &Contour, n: usize) -> Contour {
let have = c.curves();
if have >= n || have == 0 {
return c.clone();
}
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
let mut parts = vec![1usize; curves.len()];
for _ in 0..n - have {
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
if let Some(p) = parts.get_mut(best) {
*p += 1;
}
}
let mut out = Vec::with_capacity(1 + 3 * n);
out.extend(c.pts.first().copied());
for (q, p) in curves.iter().zip(&parts) {
split_curve(q, *p, &mut out);
}
Contour { closed: c.closed, pts: out }
}
const MIN_CURVES: usize = 12;
const MAX_CURVES: usize = 60;
/// Start points tried per closed contour; longer contours are sampled evenly.
const MAX_TRIES: usize = 128;
/// How many curves a contour pair gets: a common multiple of both counts while that stays small
/// (then a triangle meets a circle at matching places and the morph stays symmetric), and at least
/// [`MIN_CURVES`] for a smooth middle.
fn curves_for(na: usize, nb: usize) -> usize {
fn gcd(x: usize, y: usize) -> usize {
if y == 0 { x } else { gcd(y, x % y) }
}
let hi = na.max(nb).max(1);
let mut n = if na > 0 && nb > 0 { (na / gcd(na, nb)).saturating_mul(nb) } else { hi };
if n > MAX_CURVES {
n = hi;
}
n.saturating_mul(MIN_CURVES.div_ceil(n).max(1))
}
/// Box around all points, handles included.
fn bounds<'a>(pts: impl IntoIterator<Item = &'a Point>) -> Option<Rect> {
pts.into_iter().fold(None, |r: Option<Rect>, p| Some(r.map_or(Rect::from_points(*p, *p), |r| r.union_pt(*p))))
}
fn center(pts: &[Point]) -> Point {
bounds(pts).map(|r| r.center()).unwrap_or(Point::ZERO)
}
/// The same contour, starting at anchor `s`, optionally run backwards.
fn turn(pts: &[Point], s: usize, back: bool) -> Vec<Point> {
let q: Vec<Point> = if back { pts.iter().rev().copied().collect() } else { pts.to_vec() };
if s == 0 {
return q;
}
let body = q.get(1..).unwrap_or(&[]);
let cut = (3 * s).min(body.len());
let mut out = Vec::with_capacity(q.len());
out.extend(cut.checked_sub(1).and_then(|i| body.get(i)).copied());
out.extend_from_slice(body.get(cut..).unwrap_or(&[]));
out.extend_from_slice(body.get(..cut).unwrap_or(&[]));
out
}
/// One contour pair of equal curve count: `b` runs backwards if `back` is set, then the closed side
/// (`b` if it is closed, else `a`) gets the start that lets the points travel least. Both sides are
/// compared around their own centre, so where the shapes are does not matter.
fn match_pair(a: &Contour, b: &Contour, back: bool) -> (Contour, Contour, f64) {
let bp = if back { turn(&b.pts, 0, true) } else { b.pts.clone() };
let turn_a = !b.closed && a.closed;
let (fixed, free) = if turn_a { (&bp, &a.pts) } else { (&a.pts, &bp) };
let (fc, gc) = (center(fixed), center(free));
let tries = if turn_a || b.closed { free.len().saturating_sub(1) / 3 } else { 1 };
let mut best = free.clone();
let mut best_cost = f64::INFINITY;
for s in (0..tries.max(1)).step_by(tries.div_ceil(MAX_TRIES).max(1)) {
let q = turn(free, s, false);
let mut cost = 0.0;
for (p, f) in q.iter().zip(fixed.iter()) {
cost += ((*p - gc) - (*f - fc)).hypot2();
if cost >= best_cost {
break;
}
}
if cost < best_cost {
best_cost = cost;
best = q;
}
}
let (pa, pb) = if turn_a { (best, bp) } else { (a.pts.clone(), best) };
(Contour { closed: a.closed, pts: pa }, Contour { closed: b.closed, pts: pb }, best_cost)
}
/// Where a contour without partner shrinks to (or grows from): the spot at the same place relative
/// to the other outline's box, or its own centre without one.
fn place(c: &Contour, from: Option<Rect>, to: Option<Rect>) -> Point {
let p = center(&c.pts);
let (Some(from), Some(to)) = (from, to) else { return p };
let fx = if from.width() > 0.0 { (p.x - from.x0) / from.width() } else { 0.5 };
let fy = if from.height() > 0.0 { (p.y - from.y0) / from.height() } else { 0.5 };
Point::new(to.x0 + to.width() * fx, to.y0 + to.height() * fy)
}
/// Two contour lists brought to the same number of contours and the same number of curves per
/// contour pair. Contours pair up in order; one without partner meets a point (see [`place`];
/// `ba`/`bb` are the boxes of the whole outlines). Whether `b` runs backwards is decided once for all
/// its contours: turning single ones around would change which areas a nonzero fill covers.
pub fn align(a: &[Contour], b: &[Contour], ba: Option<Rect>, bb: Option<Rect>) -> (Vec<Contour>, Vec<Contour>) {
// (a, b, either side a point).
let mut pairs: Vec<(Contour, Contour, bool)> = Vec::new();
for k in 0..a.len().max(b.len()) {
let (ca, cb) = (a.get(k), b.get(k));
let n = curves_for(ca.map_or(0, Contour::curves), cb.map_or(0, Contour::curves));
let point = |c: &Contour, from, to, closed| Contour { closed, pts: vec![place(c, from, to); 1 + 3 * n] };
match (ca, cb) {
(Some(x), Some(y)) => pairs.push((refine(x, n), refine(y, n), false)),
(Some(x), None) => pairs.push((refine(x, n), point(x, ba, bb, x.closed), true)),
(None, Some(y)) => pairs.push((point(y, bb, ba, y.closed), refine(y, n), true)),
(None, None) => {}
}
}
let mut best: Option<(Vec<Contour>, Vec<Contour>, f64)> = None;
for back in [false, true] {
let (mut xs, mut ys, mut cost) = (Vec::new(), Vec::new(), 0.0);
for (x, y, point) in &pairs {
if *point {
xs.push(x.clone());
ys.push(if back { Contour { closed: y.closed, pts: turn(&y.pts, 0, true) } } else { y.clone() });
} else {
let (mx, my, c) = match_pair(x, y, back);
xs.push(mx);
ys.push(my);
cost += c;
}
}
if best.as_ref().is_none_or(|b| cost < b.2) {
best = Some((xs, ys, cost));
}
}
best.map(|(x, y, _)| (x, y)).unwrap_or_default()
}
// --- whole geometries ----------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq)]
struct Look {
fill: FillMode,
stroke: bool,
even_odd: bool,
}
#[derive(Clone, Debug)]
struct Part {
a: Option<Look>,
b: Option<Look>,
ca: Vec<Contour>,
cb: Vec<Contour>,
}
/// Two shape geometries brought to the same structure once, then blended per frame with
/// [`GeometryMorph::at`]. Sub-paths pair up in order; one without partner grows out of (or shrinks
/// into) a point. When a pair differs in fill mode, outline or fill rule, the two looks cross-fade
/// (see [`Fade`]).
#[derive(Clone, Debug)]
pub struct GeometryMorph {
parts: Vec<Part>,
text: (Rect, Rect),
}
fn look(s: &SubPath) -> Look {
Look { fill: s.fill, stroke: s.stroke, even_odd: s.even_odd }
}
/// How strongly a sub-path of a morphed geometry shows: its fill and its outline, 0..1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Fade {
pub fill: f64,
pub stroke: f64,
}
/// The layers a pair of looks is drawn with at `t`, bottom first. A fill or an outline only one side
/// has fades out (or in). When both fill, the side with the even-odd rule lies opaque underneath and
/// the other fades over it, so a donut's hole fills smoothly; with the same rule both cover the same
/// area, so this is a plain cross-fade (shading of 3-D faces).
fn layers(a: Option<Look>, b: Option<Look>, t: f64) -> Vec<(Look, Fade)> {
let (a, b) = match (a, b) {
(Some(a), Some(b)) if a != b => (a, b),
(Some(x), _) | (None, Some(x)) => return vec![(x, Fade { fill: 1.0, stroke: 1.0 })],
(None, None) => return vec![],
};
let side = |on_a: bool, on_b: bool| match (on_a, on_b) {
(true, true) => 1.0,
(true, false) => 1.0 - t,
(false, true) => t,
(false, false) => 0.0,
};
let (stroke, line) = (a.stroke || b.stroke, side(a.stroke, b.stroke));
let (fa, fb) = (a.fill != FillMode::None, b.fill != FillMode::None);
if fa && fb {
let (under, over, alpha) = if a.even_odd || !b.even_odd { (a, b, t) } else { (b, a, 1.0 - t) };
vec![(Look { stroke, ..under }, Fade { fill: 1.0, stroke: line }), (Look { stroke: false, ..over }, Fade { fill: alpha, stroke: 0.0 })]
} else {
let filled = if fa { a } else { b };
vec![(Look { stroke, ..filled }, Fade { fill: side(fa, fb), stroke: line })]
}
}
impl GeometryMorph {
/// `a` and `b` each in their own shape-local space.
pub fn new(a: &Geometry, b: &Geometry) -> Self {
let ca: Vec<Vec<Contour>> = a.paths.iter().map(|s| contours(&s.path)).collect();
let cb: Vec<Vec<Contour>> = b.paths.iter().map(|s| contours(&s.path)).collect();
let ba = bounds(ca.iter().flatten().flat_map(|c| &c.pts));
let bb = bounds(cb.iter().flatten().flat_map(|c| &c.pts));
let mut parts = Vec::new();
for k in 0..a.paths.len().max(b.paths.len()) {
let (xa, xb) = (ca.get(k).map(Vec::as_slice).unwrap_or(&[]), cb.get(k).map(Vec::as_slice).unwrap_or(&[]));
let (pa, pb) = align(xa, xb, ba, bb);
parts.push(Part { a: a.paths.get(k).map(look), b: b.paths.get(k).map(look), ca: pa, cb: pb });
}
GeometryMorph { parts, text: (a.text_rect, b.text_rect) }
}
/// The geometry at `t` (0..1), with a [`Fade`] for each of its sub-paths. The points of `a` are
/// first scaled by `sa` and those of `b` by `sb`, to bring both into the current box.
pub fn at(&self, t: f64, sa: Vec2, sb: Vec2) -> (Geometry, Vec<Fade>) {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let scale = |p: Point, s: Vec2| Point::new(p.x * s.x, p.y * s.y);
let (mut paths, mut fades) = (Vec::new(), Vec::new());
for part in &self.parts {
let cs: Vec<Contour> = part
.ca
.iter()
.zip(&part.cb)
.map(|(x, y)| Contour {
closed: x.closed && y.closed,
pts: x.pts.iter().zip(&y.pts).map(|(p, q)| scale(*p, sa).lerp(scale(*q, sb), t)).collect(),
})
.collect();
let path = to_path(&cs);
for (lk, fade) in layers(part.a, part.b, t) {
paths.push(SubPath { path: path.clone(), fill: lk.fill, stroke: lk.stroke, even_odd: lk.even_odd });
fades.push(fade);
}
}
let r = |r: Rect, s: Vec2| Rect::new(r.x0 * s.x, r.y0 * s.y, r.x1 * s.x, r.y1 * s.y);
let (ta, tb) = (r(self.text.0, sa), r(self.text.1, sb));
let text_rect = Rect::from_points(ta.origin().lerp(tb.origin(), t), Point::new(ta.x1, ta.y1).lerp(Point::new(tb.x1, tb.y1), t));
(Geometry { paths, text_rect, handles: vec![], sites: vec![] }, fades)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preset::build;
use kurbo::Shape as _;
fn bbox(g: &Geometry) -> Rect {
g.outline().bounding_box()
}
fn near(a: Rect, b: Rect) -> bool {
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
}
#[test]
fn contours_turn_lines_and_quads_into_cubics() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((3.0, 0.0));
p.quad_to((3.0, 3.0), (0.0, 3.0));
p.close_path();
// After a close, drawing goes on from the start point.
p.line_to((5.0, 5.0));
p.move_to((9.0, 9.0));
let cs = contours(&p);
assert_eq!(cs.len(), 2, "{cs:?}");
assert!(cs[0].closed && !cs[1].closed);
// line, quad and the closing line.
assert_eq!(cs[0].curves(), 3);
assert_eq!(cs[0].pts[1], Point::new(1.0, 0.0));
assert_eq!(cs[0].pts.last(), cs[0].pts.first());
let end = Point::new(5.0, 5.0);
assert_eq!(cs[1].pts, vec![Point::ZERO, Point::ZERO.lerp(end, 1.0 / 3.0), Point::ZERO.lerp(end, 2.0 / 3.0), end]);
// The lone move_to at the end makes no contour, and the path comes back the same.
assert_eq!(contours(&to_path(&cs)), cs);
}
#[test]
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
let mut long = c.clone();
line_to(&mut long.pts, Point::new(3.0, 30.0));
let r = refine(&long, 5);
assert_eq!(r.curves(), 5);
// The short first side stays one curve, the long one gets the other four.
assert_eq!(r.pts[3], Point::new(3.0, 0.0));
assert_eq!(r.pts.last(), Some(&Point::new(3.0, 30.0)));
assert!(r.pts.iter().all(|p| (p.x - 3.0).abs() < 1e-9 || p.y == 0.0));
assert_eq!(refine(&c, 1), c);
assert_eq!(curves_for(3, 4), 12);
assert_eq!(curves_for(4, 4), 12);
// The common multiple 77 is too many: the larger count, doubled to reach the minimum.
assert_eq!(curves_for(7, 11), 22);
assert_eq!(curves_for(0, 0), 12);
}
#[test]
fn ellipse_turns_into_a_star_and_lands_exactly() {
let (a, b) = (build("ellipse", 100.0, 100.0, &[]).unwrap(), build("star5", 200.0, 100.0, &[]).unwrap());
let m = GeometryMorph::new(&a, &b);
let one = Vec2::new(1.0, 1.0);
assert!(near(bbox(&m.at(0.0, one, one).0), bbox(&a)));
assert!(near(bbox(&m.at(1.0, one, one).0), bbox(&b)));
let mid = m.at(0.5, one, one).0;
assert_eq!(mid.paths.len(), 1);
assert!(mid.paths[0].path.elements().iter().all(|e| e.end_point().is_none_or(|p| p.is_finite())));
// Non-finite progress is the start.
assert!(near(bbox(&m.at(f64::NAN, one, one).0), bbox(&a)));
}
#[test]
fn triangle_does_not_twist_into_a_circle() {
let (a, b) = (build("triangle", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
let ca = contours(&a.paths[0].path);
let cb = contours(&b.paths[0].path);
let (x, y) = align(&ca, &cb, None, None);
// Every point travels less than the shapes are wide: no point crosses to the other side.
let far = x[0].pts.iter().zip(&y[0].pts).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max);
assert!(far < 60.0, "{far}");
}
#[test]
fn extra_sub_paths_grow_out_of_a_point() {
let (a, b) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("cube", 100.0, 100.0, &[]).unwrap());
assert!(b.paths.len() > 1);
let m = GeometryMorph::new(&a, &b);
let one = Vec2::new(1.0, 1.0);
let start = m.at(0.0, one, one).0;
assert_eq!(start.paths.len(), b.paths.len());
for sp in start.paths.iter().skip(1) {
let r = sp.path.bounding_box();
assert!(r.width() < 1e-9 && r.height() < 1e-9, "{r:?}");
}
// Looks switch at the midpoint: the cube's shaded faces show from there on.
assert_eq!(m.at(1.0, one, one).0.paths.iter().map(|s| s.fill).collect::<Vec<_>>(), b.paths.iter().map(|s| s.fill).collect::<Vec<_>>());
// And the other way round, the faces shrink away.
let back = GeometryMorph::new(&b, &a).at(1.0, one, one).0;
assert!(back.paths.iter().skip(1).all(|sp| sp.path.bounding_box().area() < 1e-9));
}
#[test]
fn differing_looks_cross_fade() {
let one = Vec2::new(1.0, 1.0);
let fades = |a: &Geometry, b: &Geometry, t: f64| {
let (g, f) = GeometryMorph::new(a, b).at(t, one, one);
g.paths.iter().zip(f).map(|(s, f)| (s.fill, s.even_odd, s.stroke, f.fill, f.stroke)).collect::<Vec<_>>()
};
// A filled rectangle into a line: the fill fades out, the outline stays.
let (r, line) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("line", 100.0, 0.0, &[]).unwrap());
assert_eq!(fades(&r, &line, 0.25), [(FillMode::Norm, false, true, 0.75, 1.0)]);
assert_eq!(fades(&line, &r, 0.25), [(FillMode::Norm, false, true, 0.25, 1.0)]);
// A donut into a circle: the donut (even-odd) lies underneath, the circle fades in over its hole.
let (d, e) = (build("donut", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
assert!(d.paths[0].even_odd && !e.paths[0].even_odd);
assert_eq!(fades(&d, &e, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.25, 0.0)]);
assert_eq!(fades(&e, &d, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.75, 0.0)]);
// Same looks: one layer, fully there.
assert_eq!(fades(&e, &r, 0.5), [(FillMode::Norm, false, true, 1.0, 1.0)]);
}
#[test]
fn scales_bring_both_sides_into_the_current_box() {
let (a, b) = (build("rect", 10.0, 10.0, &[]).unwrap(), build("ellipse", 40.0, 20.0, &[]).unwrap());
let m = GeometryMorph::new(&a, &b);
// Current box 20 x 20: a scaled up twice, b to half the width.
let g = m.at(0.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
assert!(near(g.text_rect, Rect::new(a.text_rect.x0 * 2.0, a.text_rect.y0 * 2.0, a.text_rect.x1 * 2.0, a.text_rect.y1 * 2.0)));
let g = m.at(1.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
}
#[test]
fn empty_and_degenerate_geometries_do_not_crash() {
let empty = Geometry { paths: vec![], text_rect: Rect::ZERO, handles: vec![], sites: vec![] };
let r = build("rect", 10.0, 10.0, &[]).unwrap();
let line = build("line", 10.0, 0.0, &[]).unwrap();
let mut nan = BezPath::new();
nan.move_to((f64::NAN, 0.0));
nan.line_to((1.0, 1.0));
let bad = Geometry { paths: vec![SubPath { path: nan, fill: FillMode::Norm, stroke: true, even_odd: false }], ..empty.clone() };
for (x, y) in [(&empty, &empty), (&empty, &r), (&r, &empty), (&r, &line), (&line, &r), (&bad, &r), (&r, &bad)] {
for t in [0.0, 0.5, 1.0, f64::INFINITY] {
let g = GeometryMorph::new(x, y).at(t, Vec2::new(1.0, 1.0), Vec2::new(0.0, 1e300)).0;
// At most two layers per sub-path pair.
assert!(g.paths.len() <= 2 * x.paths.len().max(y.paths.len()));
}
}
// A rectangle into a line: the line is open, so the morph ends open.
let g = GeometryMorph::new(&r, &line).at(1.0, Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0)).0;
assert!(!g.paths[0].path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)));
}
#[test]
fn long_contours_sample_their_start_points() {
// A polygon with many corners still aligns (and quickly): the start search is capped.
let mut p = BezPath::new();
for k in 0..2000 {
let a = k as f64 / 2000.0 * std::f64::consts::TAU;
let q = (50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin());
if k == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
let many = contours(&p);
let e = contours(&build("ellipse", 100.0, 100.0, &[]).unwrap().paths[0].path);
let (x, y) = align(&many, &e, None, None);
assert_eq!(x[0].curves(), y[0].curves());
assert_eq!(x[0].curves(), 2000);
}
}
+47 -12
View File
@@ -150,7 +150,7 @@ pub struct HrdParameters {
}
fn parse_hrd(r: &mut BitReader) -> Result<HrdParameters> {
let cpb_cnt = r.read_ue()? + 1;
let cpb_cnt = r.read_ue_plus(1)?;
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() };
for _ in 0..cpb_cnt {
@@ -353,8 +353,8 @@ impl Sps {
if chroma_format_idc == 3 {
separate_colour_plane = r.read_flag()?;
}
bit_depth_luma = r.read_ue()? + 8;
bit_depth_chroma = r.read_ue()? + 8;
bit_depth_luma = r.read_ue_plus(8)?;
bit_depth_chroma = r.read_ue_plus(8)?;
ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range");
bypass = r.read_flag()?;
seq_scaling_matrix_present = r.read_flag()?;
@@ -363,7 +363,7 @@ impl Sps {
scaling_lists = parse_scaling_lists(&mut r, count, None)?;
}
}
let log2_max_frame_num = r.read_ue()? + 4;
let log2_max_frame_num = r.read_ue_plus(4)?;
ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range");
let pic_order_cnt_type = r.read_ue()?;
ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range");
@@ -373,7 +373,7 @@ impl Sps {
let mut offset_for_top_to_bottom_field = 0;
let mut offset_for_ref_frame = Vec::new();
if pic_order_cnt_type == 0 {
log2_max_poc_lsb = r.read_ue()? + 4;
log2_max_poc_lsb = r.read_ue_plus(4)?;
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range");
} else if pic_order_cnt_type == 1 {
delta_pic_order_always_zero = r.read_flag()?;
@@ -388,8 +388,8 @@ impl Sps {
let max_num_ref_frames = r.read_ue()?;
ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range");
let gaps_in_frame_num_allowed = r.read_flag()?;
let pic_width_in_mbs = r.read_ue()? + 1;
let pic_height_in_map_units = r.read_ue()? + 1;
let pic_width_in_mbs = r.read_ue_plus(1)?;
let pic_height_in_map_units = r.read_ue_plus(1)?;
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
let frame_mbs_only = r.read_flag()?;
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
@@ -429,7 +429,8 @@ impl Sps {
};
if let Some((l, rr, t, b)) = sps.frame_crop {
let (cx, cy) = sps.crop_unit();
ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size");
let crop = |a: u32, b: u32, unit: u32| (u64::from(a) + u64::from(b)) * u64::from(unit);
ensure!(crop(l, rr, cx) < u64::from(sps.width()) && crop(t, b, cy) < u64::from(sps.height()), "frame cropping exceeds picture size");
}
Ok(sps)
}
@@ -572,7 +573,7 @@ impl Pps {
.ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
let num_slice_groups = r.read_ue()? + 1;
let num_slice_groups = r.read_ue_plus(1)?;
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
let mut slice_group_map_type = 0;
if num_slice_groups > 1 {
@@ -595,7 +596,7 @@ impl Pps {
r.read_ue()?;
}
6 => {
let n = r.read_ue()? + 1;
let n = r.read_ue_plus(1)?;
let bits = 32 - (num_slice_groups - 1).leading_zeros();
for _ in 0..n {
r.read_bits(bits)?;
@@ -604,8 +605,8 @@ impl Pps {
_ => {}
}
}
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
let num_ref_idx_l0_default_active = r.read_ue_plus(1)?;
let num_ref_idx_l1_default_active = r.read_ue_plus(1)?;
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
let weighted_pred = r.read_flag()?;
let weighted_bipred_idc = r.read_bits(2)?;
@@ -716,4 +717,38 @@ mod tests {
assert_eq!(sps.pic_order_cnt_type, 2);
assert_eq!(sps.crop_rect(), (0, 0, 176, 144));
}
/// The baseline SPS of `parse_minimal_baseline_sps` with a chosen width and optional cropping.
fn sps_with(width_minus1: u32, crop: Option<[u32; 4]>) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(66, 8);
w.write_bits(0xC0, 8);
w.write_bits(30, 8);
w.write_ue(0);
w.write_ue(0);
w.write_ue(2);
w.write_ue(1);
w.write_bit(false);
w.write_ue(width_minus1);
w.write_ue(8);
w.write_bit(true); // frame_mbs_only
w.write_bit(true); // direct_8x8
w.write_bit(crop.is_some());
if let Some(c) = crop {
c.iter().for_each(|&v| w.write_ue(v));
}
w.write_bit(false); // vui
w.rbsp_trailing();
w.finish()
}
#[test]
fn hostile_sps_values_are_errors_not_overflows() {
// pic_width_in_mbs_minus1 = u32::MAX: `+ 1` used to overflow (wrapping to 0 in release).
assert!(Sps::parse(&sps_with(u32::MAX, None)).is_err());
// Crop offsets whose sum or product exceed u32.
assert!(Sps::parse(&sps_with(10, Some([u32::MAX - 1, u32::MAX - 1, 0, 0]))).is_err());
assert!(Sps::parse(&sps_with(10, Some([0x8000_0000, 0, 0, 0]))).is_err());
assert!(Sps::parse(&sps_with(10, Some([1, 1, 1, 1]))).is_ok());
}
}
+2 -2
View File
@@ -199,9 +199,9 @@ impl SliceHeader {
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
if !slice_type.is_intra() {
if r.read_flag()? {
num_ref_idx_active[0] = r.read_ue()? + 1;
num_ref_idx_active[0] = r.read_ue_plus(1)?;
if slice_type.is_b() {
num_ref_idx_active[1] = r.read_ue()? + 1;
num_ref_idx_active[1] = r.read_ue_plus(1)?;
}
}
let max = if field_pic { 32 } else { 16 };
+92 -25
View File
@@ -97,7 +97,7 @@ pub fn skip_hrd(r: &mut BitReader, common: bool, max_sub_layers_minus1: u32) ->
}
let mut cpb_cnt = 1;
if !low_delay {
cpb_cnt = r.read_ue()? + 1;
cpb_cnt = r.read_ue_plus(1)?;
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
}
if nal {
@@ -259,7 +259,7 @@ impl ScalingList {
while m < 6 {
let coef_num = if size == 0 { 16 } else { 64 };
if !r.read_flag()? {
let delta = r.read_ue()? as usize * step;
let delta = (r.read_ue()? as usize).saturating_mul(step);
ensure!(delta <= m, "scaling_list_pred_matrix_id_delta out of range");
if delta == 0 {
// default list
@@ -349,12 +349,13 @@ impl StRps {
pub fn parse(r: &mut BitReader, idx: usize, sets: &[StRps], num_sets: usize) -> Result<StRps> {
let inter = if idx != 0 { r.read_flag()? } else { false };
if inter {
let delta_idx = if idx == num_sets { r.read_ue()? as usize + 1 } else { 1 };
let delta_idx = if idx == num_sets { r.read_ue_plus(1)? as usize } else { 1 };
ensure!(delta_idx <= idx, "delta_idx_minus1 out of range");
let rf = &sets[idx - delta_idx];
let sign = r.read_flag()?;
let abs = r.read_ue()? as i32 + 1;
let abs = r.read_ue_plus(1)?;
ensure!(abs <= 1 << 15, "abs_delta_rps_minus1 out of range");
let abs = abs as i32;
let delta_rps = if sign { -abs } else { abs };
let n = rf.num_delta_pocs();
let mut used = vec![false; n + 1];
@@ -406,15 +407,17 @@ impl StRps {
let mut out = StRps::default();
let mut poc = 0i32;
for _ in 0..nneg {
let d = r.read_ue()? as i32 + 1;
let d = r.read_ue_plus(1)?;
ensure!(d <= 1 << 15, "delta_poc_s0_minus1 out of range");
let d = d as i32;
poc -= d;
out.s0.push((poc, r.read_flag()?));
}
poc = 0;
for _ in 0..npos {
let d = r.read_ue()? as i32 + 1;
let d = r.read_ue_plus(1)?;
ensure!(d <= 1 << 15, "delta_poc_s1_minus1 out of range");
let d = d as i32;
poc += d;
out.s1.push((poc, r.read_flag()?));
}
@@ -491,25 +494,25 @@ impl Sps {
if r.read_flag()? {
conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?);
}
let bit_depth_luma = r.read_ue()? + 8;
let bit_depth_chroma = r.read_ue()? + 8;
let bit_depth_luma = r.read_ue_plus(8)?;
let bit_depth_chroma = r.read_ue_plus(8)?;
ensure!(bit_depth_luma <= 16 && bit_depth_chroma <= 16, "bit depth out of range");
let log2_max_poc_lsb = r.read_ue()? + 4;
let log2_max_poc_lsb = r.read_ue_plus(4)?;
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb_minus4 out of range");
let ordering_all = r.read_flag()?;
let (mut dpb, mut reorder, mut latency) = (1, 0, 0);
let start = if ordering_all { 0 } else { max_sub_layers_minus1 };
for _ in start..=max_sub_layers_minus1 {
// the values of the highest sub-layer are used
dpb = r.read_ue()? + 1;
dpb = r.read_ue_plus(1)?;
reorder = r.read_ue()?;
latency = r.read_ue()?;
}
ensure!(dpb <= 16 && reorder <= 16, "sps_max_dec_pic_buffering out of range");
let log2_min_cb = r.read_ue()? + 3;
let log2_ctb = log2_min_cb + r.read_ue()?;
let log2_min_tb = r.read_ue()? + 2;
let log2_max_tb = log2_min_tb + r.read_ue()?;
let log2_min_cb = r.read_ue_plus(3)?;
let log2_ctb = log2_min_cb.saturating_add(r.read_ue()?);
let log2_min_tb = r.read_ue_plus(2)?;
let log2_max_tb = log2_min_tb.saturating_add(r.read_ue()?);
ensure!((4..=6).contains(&log2_ctb) && log2_min_cb <= log2_ctb, "CTB size out of range");
ensure!(log2_min_tb < log2_min_cb && log2_max_tb <= log2_ctb.min(5), "transform block sizes out of range");
ensure!(width % (1 << log2_min_cb) == 0 && height % (1 << log2_min_cb) == 0, "picture size not a multiple of MinCbSizeY");
@@ -532,8 +535,8 @@ impl Sps {
if pcm {
pcm_bit_depth_luma = r.read_bits(4)? + 1;
pcm_bit_depth_chroma = r.read_bits(4)? + 1;
log2_min_pcm = r.read_ue()? + 3;
log2_max_pcm = log2_min_pcm + r.read_ue()?;
log2_min_pcm = r.read_ue_plus(3)?;
log2_max_pcm = log2_min_pcm.saturating_add(r.read_ue()?);
pcm_loop_filter_disabled = r.read_flag()?;
ensure!(pcm_bit_depth_luma <= bit_depth_luma && pcm_bit_depth_chroma <= bit_depth_chroma, "PCM bit depth out of range");
ensure!(log2_max_pcm <= log2_ctb.min(5), "PCM size out of range");
@@ -637,10 +640,10 @@ impl Sps {
pub fn crop_rect(&self) -> (u32, u32, u32, u32) {
let (l, r, t, b) = self.conf_win;
let (sw, sh) = (self.sub_width_c(), self.sub_height_c());
let x = (l * sw).min(self.width - 1);
let y = (t * sh).min(self.height - 1);
let w = self.width.saturating_sub(x + r * sw).max(1);
let h = self.height.saturating_sub(y + b * sh).max(1);
let x = l.saturating_mul(sw).min(self.width - 1);
let y = t.saturating_mul(sh).min(self.height - 1);
let w = self.width.saturating_sub(x.saturating_add(r.saturating_mul(sw))).max(1);
let h = self.height.saturating_sub(y.saturating_add(b.saturating_mul(sh))).max(1);
(x, y, w, h)
}
@@ -720,8 +723,8 @@ impl Pps {
let num_extra_slice_header_bits = r.read_bits(3)?;
let sign_data_hiding = r.read_flag()?;
let cabac_init_present = r.read_flag()?;
let num_ref_idx_l0_default = r.read_ue()? + 1;
let num_ref_idx_l1_default = r.read_ue()? + 1;
let num_ref_idx_l0_default = r.read_ue_plus(1)?;
let num_ref_idx_l1_default = r.read_ue_plus(1)?;
ensure!(num_ref_idx_l0_default <= 15 && num_ref_idx_l1_default <= 15, "num_ref_idx_default_active out of range");
let init_qp = 26 + r.read_se()?;
let constrained_intra_pred = r.read_flag()?;
@@ -742,8 +745,8 @@ impl Pps {
let (mut column_widths, mut row_heights) = (Vec::new(), Vec::new());
let mut loop_filter_across_tiles = true;
if tiles_enabled {
num_tile_columns = r.read_ue()? + 1;
num_tile_rows = r.read_ue()? + 1;
num_tile_columns = r.read_ue_plus(1)?;
num_tile_rows = r.read_ue_plus(1)?;
ensure!(num_tile_columns <= 64 && num_tile_rows <= 64, "too many tiles");
uniform_spacing = r.read_flag()?;
if !uniform_spacing {
@@ -770,7 +773,7 @@ impl Pps {
}
let scaling_list = if r.read_flag()? { Some(ScalingList::parse(&mut r)?) } else { None };
let lists_modification_present = r.read_flag()?;
let log2_parallel_merge_level = r.read_ue()? + 2;
let log2_parallel_merge_level = r.read_ue_plus(2)?;
ensure!(log2_parallel_merge_level <= 6, "log2_parallel_merge_level out of range");
let slice_header_extension_present = r.read_flag()?;
let mut range_extension = false;
@@ -933,3 +936,67 @@ impl Layout {
self.tile_id[self.rs_to_ts[rs as usize] as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_bitstream::BitWriter;
/// A 64x64 Main-profile SPS (CTB 64, no VUI) with a chosen conformance window and CTB-size delta.
fn sps_bits(conf_win: Option<[u32; 4]>, log2_diff_cb: u32) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(0, 4); // vps id
w.write_bits(0, 3); // max_sub_layers_minus1
w.write_bit(true); // temporal_id_nesting
w.write_bits(0, 2); // profile_space
w.write_bit(false); // tier
w.write_bits(1, 5); // profile_idc
w.write_bits(0x6000_0000, 32); // compatibility
w.write_bits(0b1001, 4); // progressive, interlaced, non_packed, frame_only
w.write_bits(0, 32);
w.write_bits(0, 12); // 43 constraint bits + 1 reserved
w.write_bits(93, 8); // level_idc
[0, 1, 64, 64].iter().for_each(|&v| w.write_ue(v)); // sps id, chroma 4:2:0, width, height
w.write_bit(conf_win.is_some());
conf_win.iter().flatten().for_each(|&v| w.write_ue(v));
[0, 0, 4].iter().for_each(|&v| w.write_ue(v)); // bit depths, log2_max_poc_lsb_minus4
w.write_bit(true); // sub_layer_ordering_info_present
[0, 0, 0].iter().for_each(|&v| w.write_ue(v)); // dpb, reorder, latency
w.write_ue(0); // log2_min_cb_minus3
w.write_ue(log2_diff_cb);
w.write_ue(0); // log2_min_tb_minus2
w.write_ue(3); // log2_diff_max_min_tb
w.write_ue(1); // max_transform_hierarchy_depth_inter
w.write_ue(1); // ... intra
[false; 4].iter().for_each(|&b| w.write_bit(b)); // scaling list, amp, sao, pcm
w.write_ue(0); // num_short_term_ref_pic_sets
[false; 5].iter().for_each(|&b| w.write_bit(b)); // long term, temporal mvp, strong intra, vui, extension
w.rbsp_trailing();
w.finish()
}
#[test]
fn hostile_sps_values_are_errors_not_overflows() {
assert!(Sps::parse(&sps_bits(None, 3)).is_ok());
// log2_diff_max_min_luma_coding_block_size = u32::MAX: `log2_min_cb + ue` used to overflow.
assert!(Sps::parse(&sps_bits(None, u32::MAX)).is_err());
// Conformance window offsets whose product with SubWidthC / SubHeightC exceeds u32.
let sps = Sps::parse(&sps_bits(Some([u32::MAX; 4]), 3)).unwrap();
let (x, y, w, h) = sps.crop_rect();
assert!(x < 64 && y < 64 && w >= 1 && h >= 1, "{:?}", (x, y, w, h));
}
#[test]
fn hostile_st_rps_deltas_are_errors_not_overflows() {
for ue in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
let mut w = BitWriter::new();
w.write_ue(1); // num_negative_pics
w.write_ue(0); // num_positive_pics
w.write_ue(ue); // delta_poc_s0_minus1
w.write_bit(true);
w.rbsp_trailing();
let b = w.finish();
assert!(StRps::parse(&mut BitReader::new(&b), 0, &[], 1).is_err(), "ue {ue:#x}");
}
}
}
+3 -3
View File
@@ -293,9 +293,9 @@ impl SliceHeader {
if sh.slice_type != SliceType::I {
sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
if r.read_flag()? {
sh.num_ref_idx[0] = r.read_ue()? + 1;
sh.num_ref_idx[0] = r.read_ue_plus(1)?;
if sh.is_b() {
sh.num_ref_idx[1] = r.read_ue()? + 1;
sh.num_ref_idx[1] = r.read_ue_plus(1)?;
}
}
ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
@@ -365,7 +365,7 @@ impl SliceHeader {
let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
if n > 0 {
let len = r.read_ue()? + 1;
let len = r.read_ue_plus(1)?;
ensure!(len <= 32, "offset_len_minus1 out of range");
for _ in 0..n {
sh.entry_points.push(r.read_bits(len)? + 1);
+28
View File
@@ -89,3 +89,31 @@ fn errors_are_reported_not_panics() {
let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}}));
assert_eq!(r["result"]["isError"], true);
}
#[test]
fn export_tool_lists_pdf_and_its_options() {
let mut s = Server::new(Box::new(Headless::new()));
let tools = call(&mut s, 1, "tools/list", json!({}));
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
assert!(export["description"].as_str().unwrap().contains("pdf"), "{export}");
let props = &export["inputSchema"]["properties"];
let formats = props["format"]["enum"].as_array().unwrap();
for f in ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"] {
assert!(formats.iter().any(|x| x == f), "format {f}: {export}");
}
assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"]));
assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9]));
for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] {
assert!(props.get(p).is_some(), "{p}: {export}");
}
// What the schema describes is accepted.
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
tool(&mut s, "new_presentation", json!({}));
for layout in ["slides", "notes", "handouts"] {
let path = dir.join(format!("{layout}.pdf"));
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3}));
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+15 -2
View File
@@ -115,9 +115,22 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export slides: png/jpeg (one slide, or all with all:true), pptx, outline text.",
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
obj(
json!({"path": string("Output path"), "format": string("png|jpeg|pptx|deckcraft|outline"), "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)")}),
json!({
"path": string("Output path"),
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
"slide": int("Slide index for images"),
"all": boolean("All slides (images)"),
"scale": num("Pixels per point for images (default 2)"),
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
"includeHidden": boolean("PDF: include hidden slides (default false)"),
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)")
}),
&["path"],
),
false,
+1
View File
@@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
});
};
+35 -15
View File
@@ -488,6 +488,10 @@ pub struct Slide {
/// Speaker notes.
pub notes: TextBody,
pub comments: Vec<Comment>,
/// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
/// the layout.
#[serde(skip_serializing_if = "Option::is_none")]
pub color_map: Option<Vec<(String, String)>>,
/// Unmodelled XML kept for round-trip (custom data, tags).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
@@ -507,6 +511,7 @@ impl Default for Slide {
animations: vec![],
notes: TextBody::default(),
comments: vec![],
color_map: None,
raw_ext: None,
}
}
@@ -596,6 +601,10 @@ pub struct Layout {
pub background: Option<Background>,
pub show_master_shapes: bool,
pub preserve: bool,
/// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
/// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
#[serde(skip_serializing_if = "Option::is_none")]
pub color_map: Option<Vec<(String, String)>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
}
@@ -641,24 +650,17 @@ impl Master {
}
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
let key = match slot {
SchemeSlot::Bg1 => "bg1",
SchemeSlot::Tx1 => "tx1",
SchemeSlot::Bg2 => "bg2",
SchemeSlot::Tx2 => "tx2",
other => return other,
};
self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
SchemeSlot::Bg1 => SchemeSlot::Lt1,
SchemeSlot::Tx1 => SchemeSlot::Dk1,
SchemeSlot::Bg2 => SchemeSlot::Lt2,
_ => SchemeSlot::Dk2,
})
map_slot(&self.color_map, slot)
}
/// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
pub fn scheme(&self) -> ColorScheme {
self.scheme_with(&self.color_map)
}
/// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
/// applied.
pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
let mut s = self.theme.colors.clone();
if self.color_map.is_empty() {
if map.is_empty() {
return s;
}
let base = self.theme.colors.clone();
@@ -668,12 +670,30 @@ impl Master {
(SchemeSlot::Bg2, SchemeSlot::Lt2),
(SchemeSlot::Tx2, SchemeSlot::Dk2),
] {
s.set(slot, base.get(self.map_slot(alias)));
s.set(slot, base.get(map_slot(map, alias)));
}
s
}
}
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
/// no entry).
pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
let key = match slot {
SchemeSlot::Bg1 => "bg1",
SchemeSlot::Tx1 => "tx1",
SchemeSlot::Bg2 => "bg2",
SchemeSlot::Tx2 => "tx2",
other => return other,
};
map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
SchemeSlot::Bg1 => SchemeSlot::Lt1,
SchemeSlot::Tx1 => SchemeSlot::Dk1,
SchemeSlot::Bg2 => SchemeSlot::Lt2,
_ => SchemeSlot::Dk2,
})
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Section {
+19 -4
View File
@@ -22,23 +22,27 @@ pub struct Ctx<'a> {
pub layout: Option<&'a Layout>,
pub owner: Owner,
pub scheme: ColorScheme,
/// The colour map in force: the slide's override, else its layout's, else the master's.
pub color_map: Vec<(String, String)>,
}
impl<'a> Ctx<'a> {
pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
let (master, layout) = pres.master_for(slide)?;
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() })
let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone();
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map })
}
pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() }
let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone();
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map }
}
pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() }
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() }
}
pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
let c = match &c.base {
crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => {
ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() }
ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() }
}
_ => c.clone(),
};
@@ -266,6 +270,17 @@ pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunPro
let mut r = run.clone();
let lvl = level_style(ctx, shape, para.level);
r.inherit(&lvl.run);
// Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint,
// unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline
// (`u="none"` removes it). Colours and underlines from list styles don't count.
if r.link.is_some() {
if run.fill.is_none() {
r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
}
if run.underline.is_none() {
r.underline = Some("sng".into());
}
}
r
}
+55
View File
@@ -132,3 +132,58 @@ fn four_by_three_master_scales() {
let x = title.xfrm.unwrap();
assert!(x.x + x.w <= 720.0 + 1e-6);
}
#[test]
fn hyperlinked_runs_take_the_theme_hyperlink_look() {
use crate::text::{Action, Hyperlink, Paragraph, RunProps};
let p = Presentation::default();
let s = &p.slides[0];
let ctx = Ctx::for_slide(&p, s).unwrap();
let title = &s.shapes[0];
let para = Paragraph::default();
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
// The title's master style colours its text tx1; a link is hlink and underlined instead.
assert_eq!(resolve::run(&ctx, title, &para, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))));
let r = resolve::run(&ctx, title, &para, &RunProps { link: link.clone(), ..Default::default() });
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
// A slide link looks the same.
let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false });
assert_eq!(resolve::run(&ctx, title, &para, &RunProps { link: slide, ..Default::default() }).fill, hlink);
// The run's own colour and underline win; u="none" removes the underline.
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
let own = RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() };
let r = resolve::run(&ctx, title, &para, &own);
assert_eq!((r.fill, r.underline.as_deref()), (red, Some("none")));
let dbl = resolve::run(&ctx, title, &para, &RunProps { link, underline: Some("dbl".into()), ..Default::default() });
assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl")));
}
#[test]
fn a_layout_colour_map_override_applies_to_its_slides() {
// A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master
// mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it.
let mut p = Presentation::default();
let dark: Vec<(String, String)> =
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let layout = p.slides[0].layout;
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
let colors = m.theme.colors.clone();
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone());
let s: Slide = (*p.slides[0]).clone();
let ctx = Ctx::for_slide(&p, &s).unwrap();
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2));
assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1));
// The slide's own override wins over the layout's.
let mut own = s.clone();
own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect());
let ctx = Ctx::for_slide(&p, &own).unwrap();
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
// The layout itself draws with its override; the master keeps its own map.
let m = &p.masters[0];
let l = m.layout(layout).unwrap();
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
}
+11 -1
View File
@@ -132,6 +132,8 @@ pub enum Align {
Center,
Right,
Justify,
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
JustLow,
Distributed,
}
@@ -142,6 +144,7 @@ impl Align {
Align::Center => "ctr",
Align::Right => "r",
Align::Justify => "just",
Align::JustLow => "justLow",
Align::Distributed => "dist",
}
}
@@ -150,7 +153,8 @@ impl Align {
"l" => Align::Left,
"ctr" => Align::Center,
"r" => Align::Right,
"just" | "justLow" | "thaiDist" => Align::Justify,
"just" | "thaiDist" => Align::Justify,
"justLow" => Align::JustLow,
"dist" => Align::Distributed,
_ => return None,
})
@@ -219,6 +223,10 @@ pub struct ParaProps {
pub rtl: Option<bool>,
pub font_align: Option<String>,
pub east_asian_line_break: Option<bool>,
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
pub latin_line_break: Option<bool>,
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
pub hanging_punct: Option<bool>,
}
impl ParaProps {
@@ -249,6 +257,8 @@ impl ParaProps {
self.font_align.clone_from(&p.font_align);
}
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
}
}
+18
View File
@@ -11,6 +11,24 @@ pub struct FontSet {
pub latin: String,
pub ea: String,
pub cs: String,
/// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ScriptFont>,
}
/// A theme font for one script (ISO 15924 tag, e.g. `Arab`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScriptFont {
pub script: String,
pub typeface: String,
}
impl FontSet {
/// The typeface the theme gives `script` (`"Arab"`), if any.
pub fn script(&self, script: &str) -> Option<&str> {
self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+7 -1
View File
@@ -34,8 +34,14 @@ pub enum PptxError {
}
/// Does this look like an OOXML package (zip with `[Content_Types].xml`)?
///
/// The part is looked up in the zip's central directory, not in the leading bytes: writers may
/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in).
pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml")
if !bytes.starts_with(b"PK") {
return false;
}
zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false)
}
/// Read a `.pptx` / `.potx` / `.ppsx` package.
+8
View File
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
"gradFill" => Fill::Gradient(gradient(f)),
"blipFill" => match blip_fill(imp, part, f) {
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
// tiled at its own size, as PowerPoint draws it.
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
Fill::Picture(p)
}
Some(p) => Fill::Picture(p),
None => Fill::None,
},
@@ -339,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
rtl: p.bool("rtl"),
font_align: p.attr("fontAlgn").map(String::from),
east_asian_line_break: p.bool("eaLnBrk"),
latin_line_break: p.bool("latinLnBrk"),
hanging_punct: p.bool("hangingPunct"),
default_tab: pt(p, "defTabSz"),
..Default::default()
};
+94 -1
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use deckcraft_geom::{Size, emu_to_pt};
use deckcraft_model::text::TextBody;
use deckcraft_model::{
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Properties, Section, ShowSettings, Slide, SlideId, defaults,
};
@@ -387,6 +387,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
}
inherit_placeholder_geometry(&mut p);
p.header_footer = header_footer(&p);
p.media = std::mem::take(&mut imp.media);
p.next_id = imp.next;
@@ -419,6 +420,91 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
}
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a
/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left
/// is a rectangle.
fn inherit_placeholder_geometry(p: &mut Presentation) {
let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty());
// Masters first, then layouts (from their masters), then slides (from layouts and masters).
for m in &mut p.masters {
let m = Arc::make_mut(m);
walk_shapes_mut(&mut m.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
for m in &p.masters {
for l in &m.layouts {
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
for (i, s) in l.shapes.iter().enumerate() {
if unset(&s.geom) {
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
}
}
}
}
for m in &mut p.masters {
let m = Arc::make_mut(m);
for l in &mut m.layouts {
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
let _ = id;
if let Some(s) = l.shapes.get_mut(*i) {
s.geom = g.clone();
}
}
}
}
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
for (si, sl) in p.slides.iter().enumerate() {
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
for (i, s) in sl.shapes.iter().enumerate() {
if unset(&s.geom) {
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
slide_geoms.push((si, i, g.unwrap_or_default()));
}
}
}
for (si, i, g) in slide_geoms {
if let Some(sl) = p.slides.get_mut(si)
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
{
s.geom = g;
}
}
// Placeholders inside groups (rare) and anything still unset: rectangles.
for sl in &mut p.slides {
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
for m in &mut p.masters {
for l in &mut Arc::make_mut(m).layouts {
walk_shapes_mut(&mut l.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
}
}
/// Every shape, groups included.
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
for s in shapes {
f(s);
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
walk_shapes_mut(children, f);
}
}
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
@@ -523,12 +609,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout {
l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
}
}
l.color_map = color_map_override(root);
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
}
l
}
/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none).
fn color_map_override(root: &El) -> Option<Vec<(String, String)>> {
root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map)
}
fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide {
let root = &doc.root;
let mut s = Slide {
@@ -536,6 +628,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
layout,
hidden: root.bool("show") == Some(false),
show_master_shapes: root.bool("showMasterSp").unwrap_or(true),
color_map: color_map_override(root),
..Default::default()
};
let mut ctx = shapes::IdCtx::default();
+10
View File
@@ -180,6 +180,15 @@ fn preset_geom(pg: &El) -> Geom {
Geom::Preset { name, adj }
}
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
/// marked here with an empty custom geometry, filled in once the layouts are read
/// (`inherit_placeholder_geometry`).
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
s.geom = Geom::Custom { paths: vec![] };
}
}
/// `spPr` → transform, geometry, fill, line, effects.
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
if let Some(x) = pr.child("xfrm") {
@@ -229,6 +238,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
if let Some(pr) = e.child("spPr") {
sp_pr(imp, part, &mut s, pr);
}
mark_inherited_geometry(&mut s, e.child("spPr"));
if e.bool("useBgFill") == Some(true) {
s.fill = Some(Fill::Background);
}
+10 -2
View File
@@ -2,14 +2,22 @@
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
use deckcraft_model::style::{ColorBase, Effects};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, Theme, default_format};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format};
use super::{Imp, Part, dml};
use crate::xml::El;
fn font_set(e: Option<&El>) -> FontSet {
let tf = |n: &str| e.and_then(|e| e.child(n)).and_then(|c| c.attr("typeface")).unwrap_or("").to_string();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs") }
let scripts = e
.map(|e| {
e.children_named("font")
.filter_map(|f| Some(ScriptFont { script: f.attr("script")?.to_string(), typeface: f.attr("typeface").unwrap_or("").to_string() }))
.take(256)
.collect()
})
.unwrap_or_default();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
}
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
+2
View File
@@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
.b("rtl", p.rtl)
.b("eaLnBrk", p.east_asian_line_break)
.b("latinLnBrk", p.latin_line_break)
.b("hangingPunct", p.hanging_punct)
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
let has_kids = p.line_spacing.is_some()
|| p.space_before.is_some()
+18 -7
View File
@@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [
];
fn clr_map(w: &mut W, map: &[(String, String)]) {
w.empty("p:clrMap", clr_map_attrs(map));
}
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
w.open0("p:clrMapOvr");
match map {
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
None => w.empty0("a:masterClrMapping"),
}
w.close("p:clrMapOvr");
}
/// The twelve colour map attributes, each a theme slot (the default for anything else).
fn clr_map_attrs(map: &[(String, String)]) -> A {
let mut a = A::new();
for (k, d) in CLR_KEYS {
let v = map
@@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) {
.unwrap_or(d);
a = a.a(k, v);
}
w.empty("p:clrMap", a);
a
}
fn ext_list(w: &mut W, raw: Option<&str>) {
@@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
);
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
w.open0("p:clrMapOvr");
w.empty0("a:masterClrMapping");
w.close("p:clrMapOvr");
clr_map_ovr(&mut w, l.color_map.as_deref());
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
w.close("p:sldLayout");
w.finish()
@@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
let mut w = W::new();
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
w.open0("p:clrMapOvr");
w.empty0("a:masterClrMapping");
w.close("p:clrMapOvr");
clr_map_ovr(&mut w, s.color_map.as_deref());
if let Some(t) = &s.transition {
timing::transition(&mut w, x, o, t);
}
+3
View File
@@ -13,6 +13,9 @@ fn font_set(w: &mut W, tag: &str, f: &FontSet) {
w.empty("a:latin", A::new().a("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }));
w.empty("a:ea", A::new().a("typeface", &f.ea));
w.empty("a:cs", A::new().a("typeface", &f.cs));
for sf in &f.scripts {
w.empty("a:font", A::new().a("script", &sf.script).a("typeface", &sf.typeface));
}
w.close(tag);
}
+81
View File
@@ -0,0 +1,81 @@
//! Fills read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
use deckcraft_model::{Shape, ShapeId, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
fn png() -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
.write_to(&mut out, image::ImageFormat::Png)
.expect("png");
out.into_inner()
}
/// A deck with one shape filled with a stretched picture, exported.
fn deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let media = p.add_media("tile.png", "image/png", png());
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Textured".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
..Default::default()
});
deckcraft_pptx::export(&p).expect("export")
}
fn textured_mode(bytes: &[u8]) -> PictureMode {
let q = deckcraft_pptx::import(bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
match &s.fill {
Some(Fill::Picture(pf)) => pf.mode.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
// PowerPoint tiles the picture at its own size.
let bare = edit_slides(&deck(), |s| {
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
s
});
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
// An explicit stretch is still a stretch.
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
}
/// `s` without the first `open`…`close` span inside a `blipFill`.
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
format!("{}{}", &s[..i], &s[j..])
}
+64
View File
@@ -0,0 +1,64 @@
//! Placeholder inheritance in decks written by PowerPoint.
use std::io::{Cursor, Read, Write};
use deckcraft_model::{Geom, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn a_placeholder_without_geometry_takes_its_layouts() {
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
for s in &mut l.shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
}
}
let bytes = deckcraft_pptx::export(&p).expect("export");
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
let bare = edit_slides(&bytes, |s| {
let mut s = s;
while let Some(i) = s.find("<a:prstGeom") {
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
s.replace_range(i..j, "");
}
s
});
let q = deckcraft_pptx::import(&bare).expect("import");
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
assert!(!phs.is_empty());
for s in phs {
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
}
// A placeholder with its own geometry keeps it.
let first = p.slides[0].id;
for s in &mut p.slide_mut(first).expect("slide").shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
}
}
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
}
+94
View File
@@ -686,3 +686,97 @@ fn bisect_slide_variants() {
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
}
}
#[test]
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
let mut p = rich_deck();
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
fonts.minor.cs = "Noto Naskh Arabic".into();
fonts.minor.scripts = vec![
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
];
let slide = Arc::make_mut(&mut p.slides[0]);
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
let body = sh.text.as_mut().expect("text");
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
body.paragraphs[0].props.rtl = Some(true);
body.paragraphs[0].props.align = Some(Align::JustLow);
let name = sh.name.clone();
let q = round(&p);
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
assert_eq!(para.props.rtl, Some(true));
assert_eq!(para.props.align, Some(Align::JustLow));
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
}
#[test]
fn date_fields_keep_their_type_and_saved_text() {
// An automatic date is a field (its saved text is what readers without a clock show); a fixed
// date is plain text.
let mut p = Presentation::default();
let mut auto = shape(&mut p, "Auto date", 10.0, 10.0, 200.0, 40.0);
let mut body = TextBody::from_text("");
body.paragraphs[0].runs =
vec![Run { text: "March 3, 2025".into(), props: RunProps::default(), kind: RunKind::Field { field: "datetime4".into() } }];
auto.text = Some(body);
let mut fixed = shape(&mut p, "Fixed date", 10.0, 60.0, 200.0, 40.0);
fixed.text = Some(TextBody::from_text("Launch day"));
Arc::make_mut(&mut p.slides[0]).shapes = vec![auto, fixed];
let q = round(&p);
for name in ["Auto date", "Fixed date"] {
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
}
}
#[test]
fn colour_map_overrides_round_trip() {
let mut p = defaults::new_presentation(None);
let dark: Vec<(String, String)> =
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let layout = p.slides[0].layout;
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
let q = round(&p);
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
// Layouts and slides without one keep following the master.
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
}
#[test]
fn a_picture_duotone_round_trips() {
let mut p = defaults::new_presentation(None);
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
let mut pf = PictureFill { media: img, ..Default::default() };
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(pic);
let q = round(&p);
let sh = by_name(&q.slides[0], "Duotone");
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
}
#[test]
fn line_break_options_round_trip() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
t.paragraphs[0].props.latin_line_break = Some(true);
t.paragraphs[0].props.hanging_punct = Some(false);
let q = round(&p);
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
+58
View File
@@ -0,0 +1,58 @@
//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent
//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes.
use std::io::{Cursor, Read, Write};
use deckcraft_engine::Session;
use zip::write::SimpleFileOptions;
fn sample_pptx() -> Vec<u8> {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
let p = s.doc().expect("doc").doc.as_ref().clone();
deckcraft_pptx::export(&p).expect("export")
}
/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so
/// the content-types name first appears far beyond any fixed-size prefix.
fn content_types_last(bytes: &[u8]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
out.start_file("ppt/media/padding.bin", stored).expect("start");
out.write_all(&vec![0u8; 64 * 1024]).expect("padding");
let names: Vec<String> = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect();
for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) {
let mut data = Vec::new();
src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read");
out.start_file(name, SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn sniff_accepts_content_types_anywhere_in_the_archive() {
let bytes = sample_pptx();
assert!(deckcraft_pptx::sniff(&bytes));
let moved = content_types_last(&bytes);
let first_4k = &moved[..4096.min(moved.len())];
assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix");
assert!(deckcraft_pptx::sniff(&moved));
let p = deckcraft_pptx::import(&moved).expect("import");
assert!(!p.slides.is_empty());
}
#[test]
fn sniff_rejects_non_packages() {
assert!(!deckcraft_pptx::sniff(b""));
assert!(!deckcraft_pptx::sniff(b"PK"));
assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml"));
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
out.start_file("hello.txt", SimpleFileOptions::default()).expect("start");
out.write_all(b"not an office package").expect("write");
let plain_zip = out.finish().expect("finish").into_inner();
assert!(!deckcraft_pptx::sniff(&plain_zip));
}
+1
View File
@@ -19,6 +19,7 @@ vello_common = { workspace = true }
image = { workspace = true }
log = { workspace = true }
kurbo = { workspace = true }
skrifa = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }
+212
View File
@@ -0,0 +1,212 @@
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
//! them: layers of glyph outlines filled with solid colours or gradients.
use deckcraft_color::Rgba;
use deckcraft_fonts::FontFace;
use kurbo::{Affine, BezPath, Point, Rect};
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
use skrifa::raw::types::BoundingBox;
use skrifa::{GlyphId, MetadataProvider};
use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
/// False when the glyph has no colour version, so the caller draws the plain outline.
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
let Some(font) = face.skrifa() else { return false };
let glyphs = font.color_glyphs();
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
let palette: Vec<Rgba> = font
.color_palettes()
.get(0)
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
while let Some(open) = p.opened.pop() {
if open {
p.ctx.pop_layer();
}
}
p.ctx.pop_layer();
ok
}
struct Painter<'a> {
ctx: &'a mut RenderContext,
face: &'a FontFace,
base: Affine,
/// Transforms pushed by the glyph (font units), innermost last.
stack: Vec<Affine>,
palette: Vec<Rgba>,
fg: Rgba,
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
}
fn affine(t: &Transform) -> Affine {
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
}
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
Point::new(p.x as f64, p.y as f64)
}
fn extend(e: Extend) -> peniko::Extend {
match e {
Extend::Repeat => peniko::Extend::Repeat,
Extend::Reflect => peniko::Extend::Reflect,
_ => peniko::Extend::Pad,
}
}
fn blend(m: CompositeMode) -> peniko::BlendMode {
use peniko::{Compose, Mix};
let (mix, compose) = match m {
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
CompositeMode::Src => (Mix::Normal, Compose::Copy),
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
_ => (Mix::Normal, Compose::SrcOver),
};
peniko::BlendMode::new(mix, compose)
}
impl Painter<'_> {
fn current(&self) -> Affine {
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
}
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
}
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
}
/// The glyph outline in font units, y up.
fn outline(&self, gid: GlyphId) -> BezPath {
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
}
fn push_clip(&mut self, path: &BezPath) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.set_transform(self.current());
self.ctx.push_clip_layer(path);
}
self.opened.push(open);
}
fn pop(&mut self) {
if self.opened.pop() == Some(true) {
self.ctx.pop_layer();
}
}
/// Fill the open clip area (a large box in the current transform) with `paint`.
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
self.ctx.set_transform(self.current());
self.ctx.set_paint(paint);
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
}
}
impl ColorPainter for Painter<'_> {
fn push_transform(&mut self, t: Transform) {
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
self.stack.push(next);
}
fn pop_transform(&mut self) {
if self.stack.len() > 1 {
self.stack.pop();
}
}
fn push_clip_glyph(&mut self, gid: GlyphId) {
let path = self.outline(gid);
self.push_clip(&path);
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
}
fn pop_clip(&mut self) {
self.pop();
}
fn fill(&mut self, brush: Brush<'_>) {
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
self.cover(c);
}
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
self.cover(g);
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
}
}
fn push_layer(&mut self, mode: CompositeMode) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.push_blend_layer(blend(mode));
}
self.opened.push(open);
}
fn pop_layer(&mut self) {
self.pop();
}
}
+183 -12
View File
@@ -21,6 +21,41 @@ struct Cache {
bytes: usize,
clock: u64,
bad: HashMap<usize, Arc<Vec<u8>>>,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
}
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
/// decoded at all; and the decoded size of all cached GIFs together. Hostile GIFs (millions of
/// tiny frames, a huge canvas) stop at these instead of exhausting memory.
const GIF_SIDE: u32 = 1024;
const GIF_BUDGET: usize = 64 << 20;
const GIF_MAX_FRAMES: usize = 1000;
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
const GIF_CACHE_BUDGET: usize = 192 << 20;
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
struct Anim {
frames: Vec<Arc<Pixmap>>,
ends: Vec<f64>,
}
impl Anim {
/// Decoded bytes held by the frames.
fn bytes(&self) -> usize {
self.frames.iter().map(|f| f.width() as usize * f.height() as usize * 4).sum()
}
/// The frame showing `t` seconds into the (looping) animation and the seconds until the next.
fn at(&self, t: f64) -> (usize, f64) {
let total = self.ends.last().copied().unwrap_or(0.0);
if total <= 0.0 || !t.is_finite() {
return (0, 1.0);
}
let local = t.max(0.0) % total;
let i = self.ends.partition_point(|e| *e <= local).min(self.ends.len().saturating_sub(1));
(i, (self.ends.get(i).copied().unwrap_or(total) - local).max(0.001))
}
}
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
@@ -44,9 +79,26 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
if let Some(c) = a.clear_color {
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
}
if let Some((d, l)) = duotone_rgb(a) {
for c in [d, l] {
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
}
}
h
}
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white.
fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> {
use deckcraft_model::style::ColorBase;
let (d, l) = a.duotone.as_ref()?;
let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base {
ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb,
_ => or,
};
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
}
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
if bytes.len() > 512 << 20 {
@@ -94,18 +146,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
}
apply_adjust(&mut img, adj);
let (w, h) = img.dimensions();
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
let pm = Arc::new(Pixmap::from_parts(data, w as u16, h as u16));
let pm = pixmap(&img)?;
with(|c| {
c.clock += 1;
let n = w as usize * h as usize * 4;
@@ -121,11 +162,95 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
Some(pm)
}
/// Straight RGBA to a premultiplied pixmap.
fn pixmap(img: &image::RgbaImage) -> Option<Arc<Pixmap>> {
let (w, h) = img.dimensions();
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
}
/// Decode every frame of an animated GIF (`None` for a still or broken one).
fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
use image::{AnimationDecoder, ImageDecoder};
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
dec.set_limits(image::Limits::default()).ok()?;
let (w, h) = dec.dimensions();
if w == 0 || h == 0 || (w as u64) * (h as u64) > GIF_MAX_CANVAS {
return None;
}
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
let size = nw as usize * nh as usize * 4;
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
for f in dec.into_frames() {
let Ok(f) = f else { break };
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
log::info!("animated GIF: keeping its first {} frames", frames.len());
break;
}
let (n, d) = f.delay().numer_denom_ms();
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
// Like browsers: frames of 10 ms or less show for 100 ms.
t += if ms <= 10.0 { 0.1 } else { ms / 1000.0 };
let mut img = f.into_buffer();
if img.dimensions() != (nw, nh) {
img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle);
}
apply_adjust(&mut img, adj);
frames.push(pixmap(&img)?);
ends.push(t);
}
(frames.len() > 1).then_some(Anim { frames, ends })
}
/// The decoded animation of GIF bytes, once per bytes and adjustments.
fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
if !bytes.starts_with(b"GIF8") {
return None;
}
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
return a;
}
let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
c.anims.clear();
}
c.anims.insert(key, (bytes.clone(), a.clone()));
});
a
}
/// For an animated GIF: the frame showing `t` seconds into its (looping) play and the seconds until
/// the next frame. `None` for anything else.
pub fn gif_frame(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<(usize, f64)> {
anim(bytes, adj).map(|a| a.at(t))
}
/// The frame of an animated GIF at `t` seconds.
pub(crate) fn gif_pixmap(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<Arc<Pixmap>> {
let a = anim(bytes, adj)?;
a.frames.get(a.at(t).0).cloned()
}
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
let sat = a.saturation.unwrap_or(1.0);
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
return;
}
let duo = duotone_rgb(a);
let b = a.brightness.clamp(-1.0, 1.0) * 255.0;
let c = a.contrast.clamp(-1.0, 1.0);
let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c };
@@ -145,6 +270,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
*v = y + (*v - y) * s;
*v = (*v - 128.0) * cf + 128.0 + b;
}
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
if let Some((d, l)) = duo {
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
rgb =
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
}
for (i, v) in rgb.iter().enumerate() {
if let Some(ch) = p.0.get_mut(i) {
*ch = v.round().clamp(0.0, 255.0) as u8;
@@ -152,3 +283,43 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
#[test]
fn duotone_maps_luminance_between_its_two_colours() {
use deckcraft_color::Rgba;
use deckcraft_model::style::ColorRef;
let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize]));
let adj =
PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() };
apply_adjust(&mut img, &adj);
assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour");
assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour");
let mid = img.get_pixel(1, 0).0;
assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}");
assert_eq!(mid[3], 200, "alpha is kept");
// Different duotones are cached separately.
let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() };
assert_ne!(adjust_key(&adj), adjust_key(&other));
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
}
#[test]
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
use image::{Delay, Frame, Rgba, RgbaImage};
let frames = (0..GIF_MAX_FRAMES + 200).map(|i| {
let c = if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
Frame::from_parts(RgbaImage::from_pixel(1, 1, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(20, 1))
});
let mut out = Vec::new();
image::codecs::gif::GifEncoder::new(&mut out).encode_frames(frames).expect("gif");
let a = decode_anim(&out, &PictureAdjust::default()).expect("animated");
assert_eq!(a.frames.len(), GIF_MAX_FRAMES);
assert_eq!(a.ends.len(), GIF_MAX_FRAMES);
}
}
+369 -76
View File
@@ -8,9 +8,13 @@
#![forbid(unsafe_code)]
mod chart;
mod colr;
mod images;
mod morph_path;
mod morph_text;
mod paint;
mod placed;
mod ramp;
mod table;
use std::collections::HashMap;
@@ -23,13 +27,17 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::style::{Effects, Fill, Line};
use deckcraft_model::text::TextBody;
use deckcraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide};
use deckcraft_text::datetime::{self, DateTime};
use deckcraft_text::{Fields, Opts};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::{RenderContext, Resources, peniko};
pub use images::decode as decode_image;
pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
pub use table::row_heights as table_row_heights;
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
pub const MAX_SIDE: u32 = 16_000;
@@ -140,6 +148,8 @@ pub struct RenderOpts<'a> {
pub skip: &'a [ShapeId],
/// Hide the text of this shape (the UI draws it while editing).
pub skip_text: Option<ShapeId>,
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
pub gif_times: &'a [(ShapeId, f64)],
/// Worker threads (0 = this thread; required when effects use filters).
pub threads: u16,
/// Translate the slide within the output (pixels) and output size override.
@@ -147,7 +157,10 @@ pub struct RenderOpts<'a> {
pub size: Option<(u32, u32)>,
/// Draw a background colour outside the slide (output larger than slide).
pub clear: Option<Rgba>,
/// Show this text in every date field instead of the date.
pub date_text: Option<String>,
/// The local date and time date fields show (`None`: the system clock).
pub now: Option<DateTime>,
}
impl Default for RenderOpts<'_> {
@@ -159,28 +172,45 @@ impl Default for RenderOpts<'_> {
state: None,
skip: &[],
skip_text: None,
gif_times: &[],
threads: 0,
offset: (0.0, 0.0),
size: None,
clear: None,
date_text: None,
now: None,
}
}
}
struct SlideFields {
num: u32,
date: String,
/// Text for every date field instead of the date ([`RenderOpts::date_text`]).
date: Option<String>,
footer: String,
/// The local date and time date fields show; `None`: they keep their saved text.
now: Option<DateTime>,
}
impl SlideFields {
fn new(num: u32, footer: String, opts: &RenderOpts) -> Self {
SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) }
}
}
impl Fields for SlideFields {
fn field(&self, kind: &str) -> Option<String> {
self.field_in(kind, None)
}
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
if kind == "slidenum" {
return Some(self.num.to_string());
}
if kind.starts_with("datetime") {
return Some(self.date.clone());
if datetime::is_date_field(kind) {
// An automatic date: today's, in the format of its type. (A fixed date is plain text
// in the slide's placeholder, not a field.)
return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?));
}
if kind == "footer" {
return Some(self.footer.clone());
@@ -191,11 +221,16 @@ impl Fields for SlideFields {
pub struct Renderer {
resources: Resources,
/// Outlines morphing in the frame being drawn by `blend`.
paths: Vec<PathMorph>,
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
bg: Option<(Fill, Option<Rgba>, Affine)>,
}
impl Default for Renderer {
fn default() -> Self {
Renderer { resources: Resources::new() }
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
}
}
@@ -213,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) {
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
any = true;
}
});
@@ -226,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
@@ -241,6 +290,36 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
}
}
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
/// to `shapes` only.
pub fn render_blend(
pres: &Presentation,
old: usize,
new: usize,
mix: f64,
shapes: &[(Shape, bool)],
text: &[TextMorph],
paths: &[PathMorph],
opts: &RenderOpts,
) -> Image {
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
})) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
render_slide(pres, new, opts)
}
}
}
/// Render a layout (Slide Master view thumbnail and canvas).
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
@@ -371,46 +450,52 @@ struct Frame<'a> {
}
impl Renderer {
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(
w,
h,
vello_cpu::RenderSettings {
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
..Default::default()
},
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
);
if let Some(c) = opts.clear {
ctx.set_paint(color(c, 1.0));
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
}
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
let fields = SlideFields {
num: pres.slide_number(index),
date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()),
footer: pres.header_footer.footer_text.clone(),
};
let f = Frame { pres, opts, fields };
(ctx, w, h, view)
}
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
}
/// Background plus the master and layout graphics the slide shows.
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
self.background(ctx, f, rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(f.pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &lctx, s, view, 0);
}
}
}
}
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
if let Some(rctx) = Ctx::for_slide(pres, slide) {
self.background(&mut ctx, &f, &rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &lctx, s, view, 0);
}
}
}
self.backdrop(&mut ctx, &f, &rctx, slide, view);
for s in &slide.shapes {
self.shape(&mut ctx, &f, &rctx, s, view, 0);
}
@@ -418,11 +503,69 @@ impl Renderer {
self.finish(ctx, w, h)
}
#[allow(clippy::too_many_arguments)]
fn blend(
&mut self,
pres: &Presentation,
old: (&Slide, usize),
new: (&Slide, usize),
mix: f64,
shapes: &[(Shape, bool)],
text: &[TextMorph],
paths: &[PathMorph],
opts: &RenderOpts,
) -> Image {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts };
if mix < 1.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) };
self.backdrop(&mut ctx, &f, &co, old.0, view);
}
if mix > 0.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) };
let layered = mix < 1.0;
if layered {
ctx.push_layer(None, None, Some(mix as f32), None, None);
}
self.backdrop(&mut ctx, &f, &cn, new.0, view);
if layered {
ctx.pop_layer();
}
}
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
let mut drawn: Vec<ShapeId> = Vec::new();
// Set only now: the backdrop's ids may clash with the frame's.
self.paths = paths.to_vec();
for (s, from_old) in shapes {
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
self.shape(&mut ctx, f, c, s, view, 0);
continue;
};
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
drawn.push(s.id);
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
continue;
}
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
}
}
self.paths.clear();
self.finish(ctx, w, h)
}
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
let f = Frame { pres, opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) };
if let Some(m) = pres.masters.get(mi) {
match li.and_then(|i| m.layouts.get(i)) {
Some(l) => {
@@ -464,7 +607,12 @@ impl Renderer {
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
ctx.set_transform(view);
let path = r.to_path(0.1);
// PowerPoint lays every background over white: a picture stretched into part of the
// slide, or a see-through colour, leaves white around and under it.
ctx.set_paint(color(Rgba::WHITE, 1.0));
ctx.fill_path(&path);
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
self.bg = Some((fill, ph, view));
}
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
@@ -529,7 +677,11 @@ impl Renderer {
}
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
let geo = shape_geometry(s, w, h);
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
Some(pm) => morph_path::geometry(pm, s, w, h),
None => (shape_geometry(s, w, h), Vec::new()),
};
let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });
let (fill, fill_ph) = resolve::fill(rctx, s);
let (line, line_ph) = resolve::line(rctx, s);
let (effects, fx_ph) = resolve::effects(rctx, s);
@@ -554,7 +706,8 @@ impl Renderer {
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
paint::picture(ctx, f.pres, pf, bounds, m);
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
ctx.pop_layer();
}
ShapeKind::Media(mc) => {
@@ -562,7 +715,7 @@ impl Renderer {
let bounds = Rect::new(0.0, 0.0, w, h);
if let Some(p) = mc.poster {
let pf = deckcraft_model::style::PictureFill { media: p, ..Default::default() };
paint::picture(ctx, f.pres, &pf, bounds, m);
paint::picture(ctx, f.pres, &pf, bounds, m, None);
} else if mc.video {
ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
ctx.fill_rect(&bounds);
@@ -575,13 +728,22 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none())
{
for sp in &geo.paths {
if sp.fill == FillMode::None {
for (i, sp) in geo.paths.iter().enumerate() {
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
continue;
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, 1.0, f.pres);
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
@@ -590,8 +752,8 @@ impl Renderer {
// Outline.
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
let lw = line.width.unwrap_or(0.75).max(0.0);
for sp in geo.paths.iter().filter(|p| p.stroke) {
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw);
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
}
if s.is_line() || geo.is_open() {
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
@@ -746,7 +908,7 @@ fn blur(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
}
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64) {
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64, alpha: f64) {
use deckcraft_model::style::{LineCap, LineJoin};
let cap = match line.cap.unwrap_or_default() {
LineCap::Flat => kurbo::Cap::Butt,
@@ -770,13 +932,13 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
ctx.set_transform(m);
match &line.fill {
Some(Fill::Solid { color: c }) => {
ctx.set_paint(color(rctx.color(c, ph), 1.0));
ctx.set_paint(color(rctx.color(c, ph), alpha));
}
Some(Fill::Gradient(g)) => {
let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK);
ctx.set_paint(color(c, 1.0));
ctx.set_paint(color(c, alpha));
}
_ => ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)),
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
}
ctx.set_stroke(sk);
ctx.stroke_path(path);
@@ -936,6 +1098,25 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
draw_layout(ctx, &l, tr, m);
}
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
///
/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's
/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about
/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180°
/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this.
pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
let c = tr.center().to_vec2();
let mut t = m;
if m.determinant() < 0.0 && c.is_finite() {
t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c);
}
if rotation != 0.0 && rotation.is_finite() && c.is_finite() {
t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c);
}
t
}
/// Draw an already laid-out text block.
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
draw_layout_with(ctx, l, tr, m, &[]);
@@ -944,12 +1125,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
/// Draw a laid-out text block with per-paragraph animation states.
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = if l.rotation != 0.0 {
let c = tr.center().to_vec2();
m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c)
} else {
m
};
let m = text_transform(m, tr, l.rotation);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
@@ -962,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
@@ -971,30 +1156,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
let alpha = run.alpha * ps.opacity;
let k = run.size / run.face.upem.max(1.0);
ctx.set_paint(color(run.color, alpha));
// A gradient fill starts over on every line and spans that line's text (from its top to
// its bottom, from its first to its last character), as in PowerPoint.
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
let at = m * Affine::translate((*x, *y));
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
ctx.set_transform(gm);
if alpha > 0.0 && run.color.a > 0 {
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, ps.opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
ctx.set_paint(color(run.color, alpha));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1002,6 +1181,120 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
l.lines
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let k = run.size / run.face.upem.max(1.0);
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
// strokes the plain glyph below.
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
let rctx = Ctx::for_slide(pres, slide)?;
@@ -1012,7 +1305,7 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64)
let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let opts = RenderOpts { scale, ..Default::default() };
let f = Frame { pres, opts: &opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
RENDERER.with(|r| {
let mut r = r.borrow_mut();
+40
View File
@@ -0,0 +1,40 @@
//! Morph of an outline: a frame shape whose geometry turns from the old shape's into its own (a
//! circle into a star), with the points aligned by `deckcraft_geom::morph`.
use deckcraft_geom::Xfrm;
use deckcraft_geom::morph::{Fade, GeometryMorph};
use deckcraft_geom::preset::Geometry;
use deckcraft_model::{Geom, Shape, ShapeId};
use kurbo::Vec2;
use crate::shape_geometry;
/// A frame shape (see [`crate::render_blend`]) whose outline morphs from `from` into its own
/// geometry.
#[derive(Clone, Debug, PartialEq)]
pub struct PathMorph {
pub id: ShapeId,
/// The old shape's geometry.
pub from: Geom,
/// The boxes of the old and the new shape on their own slides.
pub from_box: Xfrm,
pub to_box: Xfrm,
/// Progress, 0..1 (already eased).
pub t: f64,
}
/// `now / own`, or 1 when that is no usable scale (a line's zero height).
fn ratio(now: f64, own: f64) -> f64 {
let r = now / own;
if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 }
}
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their
/// own sizes (so the alignment does not change while the box grows), then scaled into the box.
/// With a [`Fade`] per sub-path.
pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h);
let b = shape_geometry(s, pm.to_box.w, pm.to_box.h);
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box))
}
+230
View File
@@ -0,0 +1,230 @@
//! Morph by words or characters: the words (or characters) two texts share travel from their place
//! in the old text to their place in the new one, growing or shrinking to the new size; the rest
//! of the old text fades out and the rest of the new one fades in, both riding the moving box.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::Ctx;
use deckcraft_model::{Shape, ShapeId};
use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout};
use kurbo::{Affine, Point, Vec2};
use vello_cpu::RenderContext;
use crate::{color, draw_glyph, draw_layout, shape_geometry, text_transform};
/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextMorph {
/// The frame shape from the old slide and the one from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// Their boxes on their own slides: where the shared words start and end.
pub from: Xfrm,
pub to: Xfrm,
/// Characters instead of words.
pub chars: bool,
/// Progress, 0..1 (already eased).
pub t: f64,
}
/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph.
struct Token {
key: String,
glyphs: Vec<(usize, usize)>,
origin: Point,
run: usize,
}
/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets).
fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new();
let mut loose = Vec::new();
for (ri, run) in l.runs.iter().enumerate() {
for gi in 0..run.glyphs.len() {
match run.cells.get(gi) {
Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)),
None => loose.push((ri, gi)),
}
}
}
cells.sort_by_key(|c| (c.0, c.1, c.3, c.4));
let mut out: Vec<Token> = Vec::new();
let mut last: Option<(usize, usize)> = None;
for (para, ci, ch, ri, gi) in cells {
if ch.is_whitespace() {
last = None;
continue;
}
let same_char = last == Some((para, ci));
let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci));
let extend = same_char || (!chars && next_char);
match out.last_mut() {
Some(tok) if extend => {
if !same_char {
tok.key.push(ch);
}
tok.glyphs.push((ri, gi));
}
_ => {
let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO);
out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri });
}
}
last = Some((para, ci));
}
(out, loose)
}
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
let body = s.text.as_ref().filter(|b| !b.is_empty())?;
let rect = shape_geometry(s, x.w, x.h).text_rect;
Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false }))
}
fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> {
let run = l.runs.get(ri)?;
let g = run.glyphs.get(gi)?;
Some((run, g.0, Point::new(g.1, g.2)))
}
fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {
a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both
/// frame shapes share at this moment, `view` the slide-to-pixels transform.
pub(crate) fn draw(
ctx: &mut RenderContext,
(ca, a, fa): (&Ctx, &Shape, &dyn Fields),
(cb, b, fb): (&Ctx, &Shape, &dyn Fields),
m: &TextMorph,
now: Xfrm,
view: Affine,
) {
let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 };
let la = layout(ca, a, m.from, fa);
let lb = layout(cb, b, m.to, fb);
let mt = view * now.affine();
// Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box.
let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0);
if rotated || la.is_none() || lb.is_none() {
for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] {
if let Some(l) = l
&& alpha > 0.001
{
ctx.push_layer(None, None, Some(alpha as f32), None, None);
draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt);
ctx.pop_layer();
}
}
return;
}
let (Some(la), Some(lb)) = (la, lb) else { return };
let (ta, loose_a) = tokens(&la, m.chars);
let (tb, loose_b) = tokens(&lb, m.chars);
let pairs = matches(&ta, &tb);
// Flipped boxes keep their text readable, as when drawn (see `text_transform`).
let (tra, trb) = (shape_geometry(a, m.from.w, m.from.h).text_rect, shape_geometry(b, m.to.w, m.to.h).text_rect);
let (ma, mb) = (text_transform(view * m.from.affine(), tra, 0.0), text_transform(view * m.to.affine(), trb, 0.0));
let (mta, mtb) = (text_transform(mt, tra, 0.0), text_transform(mt, trb, 0.0));
let c = mta.as_coeffs();
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
let mut moved_a = vec![false; ta.len()];
// Shared tokens travel.
for (j, i) in pairs.iter().enumerate() {
let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue };
let Some(tok_a) = ta.get(i) else { continue };
if let Some(f) = moved_a.get_mut(i) {
*f = true;
}
let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue };
let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
let same = same_look(ra, rb);
let alpha_a = if same { 1.0 } else { 1.0 - t };
let sa = lerp(1.0, ratio, t);
for &g in &tok_a.glyphs {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin);
draw_glyph(ctx, run, gid, at, alpha_a);
}
}
if !same {
let sb = lerp(1.0 / ratio, 1.0, t);
for &g in &tok_b.glyphs {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin);
draw_glyph(ctx, run, gid, at, t);
}
}
}
}
// The rest fades while following the shared tokens: they define a scale and shift from the old
// text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as
// one. With nothing shared it rides the moving box.
let shared: Vec<(Point, Point, f64)> = pairs
.iter()
.enumerate()
.filter_map(|(j, i)| {
let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?);
let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
Some((ma * tok_a.origin, mb * tok_b.origin, ratio))
})
.collect();
let n = shared.len() as f64;
let s = shared.iter().map(|x| x.2).sum::<f64>() / n;
let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n;
let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() {
// Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t).
let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t));
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
(a * ma, b * mb)
} else {
(mta, mtb)
};
let unmatched_a =
ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_a.chain(loose_a) {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t);
}
}
let unmatched_b =
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_b.chain(loose_b) {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
}
}
// Underlines, strikethroughs and highlights fade with their text.
for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] {
for d in &l.decos {
ctx.set_transform(place);
ctx.set_paint(color(d.color, alpha));
ctx.fill_rect(&d.rect);
}
}
}
#[cfg(test)]
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
+51 -31
View File
@@ -3,7 +3,7 @@
use deckcraft_color::Rgba;
use deckcraft_geom::preset::FillMode;
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode};
use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode};
use deckcraft_model::{PhType, Presentation};
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
@@ -35,37 +35,13 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
let mut stops: Vec<(f32, peniko::Color)> =
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
let grad = match &g.shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
ctx.fill_path(path);
}
Fill::Picture(pf) => {
ctx.push_clip_layer(path);
picture(ctx, pres, pf, bounds, m);
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
ctx.pop_layer();
}
Fill::Pattern(p) => {
@@ -120,15 +96,59 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
}
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine) {
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
/// cache can apply and key them without a colour scheme.
pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::borrow::Cow<'a, PictureFill> {
let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) };
let mut out = pf.clone();
out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph))));
std::borrow::Cow::Owned(out)
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
for s in &mut stops {
s.0 = s.0.clamp(0.0, 1.0);
}
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
match shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
}
}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
let Some(item) = pres.media(pf.media) else {
missing(ctx, bounds);
return;
};
let px_scale = crate::scale_of(m);
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
let Some(pm) = images::decode_for(&item.data, want, &pf.adjust) else {
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
missing(ctx, bounds);
return;
};
+31 -23
View File
@@ -7,12 +7,12 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry};
use crate::{SlideFields, shape_geometry, table};
/// One shape's text as drawn on the slide.
pub struct PlacedText {
pub layout: TextLayout,
/// Shape-local → slide points (text rotation included).
/// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]).
pub transform: Affine,
pub body: TextBody,
}
@@ -35,8 +35,7 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
let mut out = Placed::default();
let Some(slide) = pres.slides.get(index) else { return out };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
let fields =
SlideFields { num: pres.slide_number(index), date: pres.header_footer.date_text.clone(), footer: pres.header_footer.footer_text.clone() };
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
@@ -76,32 +75,41 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
place(out, rctx, fields, c, gm, depth + 1);
}
}
ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it.
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
place_text(out, rctx, fields, &tmp, &body, rect, m);
}
}
ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
_ => {
let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return };
let tr = shape_geometry(s, x.w, x.h).text_rect;
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = if layout.rotation != 0.0 {
let c = tr.center().to_vec2();
m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c)
} else {
m
};
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0);
let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last);
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
}
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
place_text(out, rctx, fields, s, body, tr, m);
}
}
}
/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = crate::text_transform(m, tr, layout.rotation);
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
// The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
}
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
}
/// The hyperlink of the model run containing character `at` of paragraph `para`.
fn run_link(body: &TextBody, para: usize, at: usize) -> Option<Hyperlink> {
let p = body.paragraphs.get(para)?;
+128
View File
@@ -0,0 +1,128 @@
//! Gradient colour ramps as PowerPoint draws them.
//!
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
//! pieces whose colours follow PowerPoint's ramp.
use deckcraft_color::Rgba;
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
const WEIGHT: [f64; 17] =
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
/// Pieces each segment between two stops is cut into.
const PIECES: usize = 16;
/// The next stop's share at `t` (0..1) between two stops.
pub fn weight(t: f64) -> f64 {
if !t.is_finite() {
return 0.0;
}
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
let f = x - i as f64;
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
a + (b - a) * f
}
fn to_linear(v: u8) -> f64 {
let v = v as f64 / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn to_srgb(v: f64) -> u8 {
let v = v.clamp(0.0, 1.0);
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
}
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
let w = weight(t);
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
}
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
/// anything else unchanged.
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
if !two_colour {
return stops.to_vec();
}
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
for (i, &(p, c)) in stops.iter().enumerate() {
out.push((p, c));
let Some(&(q, d)) = stops.get(i + 1) else { continue };
if q - p <= 1e-6 || c == d {
continue;
}
for k in 1..PIECES {
let t = k as f64 / PIECES as f64;
out.push((p + (q - p) * t, mix(c, d, t)));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn black_to_white_follows_powerpoint() {
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
let got = mix(k, w, t).r as i32;
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
}
}
#[test]
fn colour_pairs_follow_powerpoint() {
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
// Red to blue a quarter of the way: (238, 0, 105).
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
}
#[test]
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
assert_eq!(mix(a, b, 0.0), a);
assert_eq!(mix(a, b, 1.0), b);
for k in 0..=32 {
let t = k as f64 / 32.0;
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
}
assert_eq!(weight(f64::NAN), 0.0);
}
#[test]
fn expand_keeps_stops_and_hard_edges() {
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
// Two equal colours add nothing.
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
let e = expand(&[(0.0, r), (1.0, g)]);
assert_eq!(e.len(), PIECES + 1);
assert_eq!(e.first(), Some(&(0.0, r)));
assert_eq!(e.last(), Some(&(1.0, g)));
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
}
}
+73 -39
View File
@@ -3,9 +3,10 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::Table;
use deckcraft_model::text::Anchor;
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::text::{Anchor, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape};
use deckcraft_text::Fields;
use kurbo::{Affine, BezPath, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
@@ -94,21 +95,76 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
}
}
/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
fn edges(sizes: &[f64]) -> Vec<f64> {
std::iter::once(0.0)
.chain(sizes.iter().scan(0.0, |a, s| {
*a += s.max(0.0);
Some(*a)
}))
.collect()
}
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look(rctx, t, r, c);
let mut body = cell.text.clone();
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
body.body.inset_l = Some(ml);
body.body.inset_r = Some(mr);
body.body.inset_t = Some(mt);
body.body.inset_b = Some(mb);
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
if cell.vertical.is_some() {
body.body.vert = cell.vertical;
}
for p in &mut body.paragraphs {
for run in &mut p.runs {
if run.props.fill.is_none()
&& let Some(tc) = lk.text
{
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
}
if lk.bold && run.props.bold.is_none() {
run.props.bold = Some(true);
}
}
}
body
}
/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
/// Cells spanning several rows and vertical text keep the stored height.
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
let xs = edges(&t.cols);
let mut out = Vec::with_capacity(t.rows.len());
for (ri, row) in t.rows.iter().enumerate() {
let mut h = row.height.max(0.0);
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
if cell.is_covered() || cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) {
continue;
}
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
let body = cell_body(rctx, t, ri, ci, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
if need.is_finite() {
h = h.max(need.min(20_000.0));
}
}
out.push(h);
}
out
}
/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
/// `heights` are the drawn row heights ([`row_heights`]).
pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
let mut out = vec![];
let xs: Vec<f64> = std::iter::once(0.0)
.chain(t.cols.iter().scan(0.0, |a, w| {
*a += w.max(0.0);
Some(*a)
}))
.collect();
let ys: Vec<f64> = std::iter::once(0.0)
.chain(t.rows.iter().scan(0.0, |a, r| {
*a += r.height.max(0.0);
Some(*a)
}))
.collect();
let xs = edges(&t.cols);
let ys = edges(heights);
for (ri, row) in t.rows.iter().enumerate() {
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
if cell.is_covered() {
@@ -125,7 +181,7 @@ pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t);
let rects = cell_rects(t, &row_heights(rctx, t, fields));
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
@@ -180,29 +236,7 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
if cell.text.is_empty() {
continue;
}
let lk = look(rctx, t, *r, *c);
let mut body = cell.text.clone();
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
body.body.inset_l = Some(ml);
body.body.inset_r = Some(mr);
body.body.inset_t = Some(mt);
body.body.inset_b = Some(mb);
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
if cell.vertical.is_some() {
body.body.vert = cell.vertical;
}
for p in &mut body.paragraphs {
for run in &mut p.runs {
if run.props.fill.is_none()
&& let Some(tc) = lk.text
{
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
}
if lk.bold && run.props.bold.is_none() {
run.props.bold = Some(true);
}
}
}
let body = cell_body(rctx, t, *r, *c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
let _ = peniko::Fill::NonZero;
+593
View File
@@ -157,3 +157,596 @@ fn custom_path_parse() {
assert!(p.elements().len() >= 5);
assert!(parse_path("").elements().is_empty());
}
#[test]
fn table_rows_of_zero_height_grow_to_fit_text() {
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
let mut t = Table::new(3, 2, 300.0, 0.0);
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]].iter().enumerate() {
for (c, text) in row.iter().enumerate() {
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
}
}
let tbl = Shape { id: ShapeId(610), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), kind: ShapeKind::Table(t.clone()), ..Default::default() };
let p = deck_with(vec![tbl]);
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
assert_eq!(hs.len(), 3);
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs).iter().filter(|(_, c, _)| *c == 0).map(|(_, _, r)| r.y0).collect();
assert!(tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0), "{tops:?}");
// A taller stored height is kept.
let mut tall = t;
tall.rows[1].height = 200.0;
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
// The PDF text layer gets every cell's text, rows one below the other.
let placed = place_slide(&p, 0);
let top = |s: &str| {
let pt = placed.texts.iter().find(|pt| pt.body.text() == s).unwrap_or_else(|| panic!("no placed text {s:?}"));
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
};
assert!(top("Col A") < top("1") && top("1") < top("2"));
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
assert!(top("Col B") >= 100.0);
}
#[test]
fn blend_ends_match_the_slides_and_mixes_backdrops() {
let sh = Shape {
id: ShapeId(500),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![sh.clone()]);
let mut dark = (*p.slides[0]).clone();
dark.background =
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
p.slides.push(std::sync::Arc::new(dark));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let shapes = vec![(sh, false)];
// At the ends the frame is the slide itself; the shape stays opaque throughout.
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels);
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o);
let px = mid.pixel(5, 5);
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
// Hostile input: bad indices and NaN never panic.
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0);
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o);
}
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
let mut t = TextBody::from_text(text);
for p in &mut t.paragraphs {
for r in &mut p.runs {
r.props.size = Some(40.0);
}
}
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
}
#[test]
fn morph_text_tokens_are_words_or_characters() {
let p = Presentation::default();
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
let l = deckcraft_text::layout(
&rctx,
&s,
s.text.as_ref().unwrap(),
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
}
#[test]
fn morph_text_ends_match_the_slides() {
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let frame = |t: f64| {
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
sb.xfrm = Some(x);
sa.xfrm = Some(x);
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o)
};
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
// Halfway the shared words are on their way: ink between the two texts' rows.
let mid = frame(0.5);
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
assert!(ink > 50, "ink {ink}");
}
/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`.
fn field_shape(kind: Option<&str>, saved: &str) -> Shape {
let mut sh = text_shape(1, saved, 40.0, 40.0);
if let (Some(k), Some(run)) = (kind, sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut())) {
run.kind = deckcraft_model::text::RunKind::Field { field: k.into() };
}
sh
}
#[test]
fn date_fields_show_the_date_in_their_format() {
use deckcraft_text::datetime::DateTime;
let fri = DateTime::new(2026, 10, 9, 20, 5, 7);
let at = |p: &Presentation, now: DateTime| render_slide(p, 0, &RenderOpts { scale: 0.5, now: Some(now), ..Default::default() });
let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let _ = at(&p, fri); // faces load lazily
// The field shows the date (it changes with the clock), not its saved text.
let a = at(&p, fri);
assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0);
let shown = deck_with(vec![field_shape(None, "10/9/2026")]);
assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY");
// Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it.
p.header_footer.date_text = "Fixed on another slide".into();
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
// Every type has its format; the run's language orders numeric dates.
let f = Renderer::fields(&p, 0, &RenderOpts { now: Some(fri), ..Default::default() });
assert_eq!(f.field_in("datetime4", Some("en-US")).as_deref(), Some("October 9, 2026"));
assert_eq!(f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(), Some("09/10/2026"));
assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM"));
assert_eq!(f.field("slidenum").as_deref(), Some("1"));
// A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text.
assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None);
let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]);
let plain = deck_with(vec![field_shape(None, "Saved text")]);
assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0);
// An explicit override still wins.
let o = RenderOpts { date_text: Some("Today".into()), now: Some(fri), ..Default::default() };
assert_eq!(Renderer::fields(&p, 0, &o).field("datetime2").as_deref(), Some("Today"));
}
/// Channel values that differ noticeably between two images of the same size.
fn diff_px(a: &Image, b: &Image) -> usize {
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
}
/// Two 8×8 frames, red then blue, 100 ms each.
fn two_frame_gif() -> Vec<u8> {
use image::{Delay, Frame, Rgba, RgbaImage};
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
let mut out = Vec::new();
image::codecs::gif::GifEncoder::new(&mut out).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
out
}
#[test]
fn animated_gif_shows_the_frame_for_its_time() {
let mut p = Presentation::default();
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let at = |t: Option<f64>| {
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
render_slide(&p, 0, &RenderOpts { scale: 0.1, gif_times: &times, ..Default::default() }).pixel(48, 27)
};
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
assert!(red(at(None)), "no time: the first frame, as exports draw it");
assert!(red(at(Some(0.05))));
assert!(blue(at(Some(0.15))));
assert!(red(at(Some(0.25))), "it loops");
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
let adj = Default::default();
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
}
#[test]
fn morph_path_ends_match_the_slides() {
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);
let to = Xfrm::new(500.0, 150.0, 300.0, 150.0);
let form = |id: u32, g: &str, x: Xfrm| Shape {
id: ShapeId(id),
xfrm: Some(x),
geom: Geom::preset(g),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![form(1, "ellipse", from)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![form(1, "star5", to)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let frame = |t: f64| {
let lerp = |a: f64, b: f64| a + (b - a) * t;
let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h));
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o)
};
// The morph splits the outlines into more curves, which flatten slightly differently: edge pixels
// may differ a little, a different shape differs a lot.
let differ = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 60).count();
// At the start the star is still the circle, at the end the star.
let d0 = differ(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
// Without the morph the start would show the star.
let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o);
assert!(differ(&star, &render_slide(&p, 0, &o)) > 400);
// Halfway the shape is filled around the middle of its box.
let mid = frame(0.5);
let c = (from.center().to_vec2() + to.center().to_vec2()) * 0.5 * 0.5;
assert_ne!(mid.pixel(c.x as u32, c.y as u32), [255, 255, 255, 255]);
}
#[test]
fn morph_path_fades_a_fill_only_one_side_has() {
let bx = Xfrm::new(100.0, 100.0, 200.0, 200.0);
let form = |id: u32, g: &str| Shape {
id: ShapeId(id),
xfrm: Some(bx),
geom: Geom::preset(g),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![form(1, "rect")]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![form(1, "line")];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let at = |t: f64| {
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t };
// A point near the line but not on it: inside the shape all the way.
render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90)
};
let (full, half, none) = (at(0.0), at(0.5), at(1.0));
assert_eq!(none, [255, 255, 255, 255]);
assert!(full[0] < 128, "{full:?}");
// Halfway the fill is half there: between the full colour and the white slide.
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
}
#[test]
fn text_transform_unmirrors_flips() {
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
let tr = Rect::new(10.0, 0.0, 40.0, 50.0);
let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p;
let near = |a: Point, b: Point| (a - b).hypot() < 1e-9;
let base = Xfrm::new(0.0, 0.0, 100.0, 50.0);
// flipH: the rect moves to 60..90 and the text still runs left to right.
let h = Xfrm { flip_h: true, ..base };
assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0)));
assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0)));
// flipV: the text is turned 180° in its (vertically mirrored) rect.
let v = Xfrm { flip_v: true, ..base };
assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0)));
assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0)));
// Both flips: the same as a 180° turn.
let hv = Xfrm { flip_h: true, flip_v: true, ..base };
let turned = Xfrm { rot: 180.0, ..base };
for p in [Point::new(10.0, 0.0), Point::new(40.0, 50.0), Point::new(25.0, 10.0)] {
assert!(near(at(hv, p), at(turned, p)));
}
// Unflipped and hostile input: plain transform, nothing NaN from the helper itself.
assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine());
let _ = text_transform(h.affine(), Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0), f64::NAN);
}
/// Slide with text box `x` holding left-aligned, asymmetric text.
fn flipped_text(x: Xfrm, group: Option<Xfrm>) -> Image {
let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0);
s.xfrm = Some(x);
let s = match group {
Some(g) => Shape {
id: ShapeId(2),
xfrm: Some(g),
kind: ShapeKind::Group { children: vec![s], child: Xfrm::new(0.0, 0.0, 600.0, 120.0) },
..Default::default()
},
None => s,
};
render_slide(&deck_with(vec![s]), 0, &RenderOpts { scale: 0.5, ..Default::default() })
}
fn differing(a: &Image, b: &Image) -> usize {
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
}
#[test]
fn flipped_shapes_draw_readable_text() {
let base = Xfrm::new(180.0, 200.0, 600.0, 120.0);
let plain = flipped_text(base, None);
// Warm up: faces load lazily, so the first render may still fall back.
let plain = if differing(&plain, &flipped_text(base, None)) > 0 { flipped_text(base, None) } else { plain };
let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None);
assert!(differing(&plain, &turned) > 200, "the comparisons below must mean something");
// flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°.
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..base }, None));
assert!(d < 40, "flipH mirrored the text: {d}");
let d = differing(&turned, &flipped_text(Xfrm { flip_v: true, ..base }, None));
assert!(d < 40, "flipV mirrored the text: {d}");
let d = differing(&turned, &flipped_text(Xfrm { flip_h: true, flip_v: true, ..base }, None));
assert!(d < 40, "flipH+flipV: {d}");
// Rotation then flipH: still the rotated text, not its mirror image.
let rot = Xfrm { rot: 30.0, ..base };
let d = differing(&flipped_text(rot, None), &flipped_text(Xfrm { flip_h: true, ..rot }, None));
assert!(d < 40, "rotated flipH: {d}");
// Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither.
let child = Xfrm::new(0.0, 0.0, 600.0, 120.0);
let d = differing(&plain, &flipped_text(child, Some(Xfrm { flip_h: true, ..base })));
assert!(d < 40, "group flipH: {d}");
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..child }, Some(Xfrm { flip_h: true, ..base })));
assert!(d < 40, "flipH child in flipH group: {d}");
let d = differing(&turned, &flipped_text(child, Some(Xfrm { flip_v: true, ..base })));
assert!(d < 40, "group flipV: {d}");
}
/// A text box with one run of `props` (40 pt).
fn run_deck(props: deckcraft_model::RunProps) -> Presentation {
let mut sh = text_shape(1, "Linked text", 40.0, 40.0);
if let Some(run) = sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut()) {
run.props = deckcraft_model::RunProps { size: Some(40.0), ..props };
}
deck_with(vec![sh])
}
#[test]
fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
use deckcraft_model::RunProps;
use deckcraft_model::text::{Action, Hyperlink};
let o = RenderOpts { scale: 0.5, ..Default::default() };
let px = |p: &Presentation| render_slide(p, 0, &o);
let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count();
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink)));
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
let _ = px(&run_deck(RunProps::default())); // faces load lazily
let linked = px(&run_deck(RunProps { link: link.clone(), ..Default::default() }));
assert!(diff(&linked, &px(&run_deck(RunProps::default()))) > 100, "a link doesn't look like plain text");
// Exactly the theme's hyperlink colour, single underline.
let styled = px(&run_deck(RunProps { fill: hlink, underline: Some("sng".into()), ..Default::default() }));
assert_eq!(diff(&linked, &styled), 0);
// A colour on the run itself wins; u="none" drops the underline.
let own = px(&run_deck(RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() }));
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red.clone(), ..Default::default() }))), 0);
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
}
#[test]
fn a_linear_gradient_ramps_like_powerpoint() {
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
// (linear-light mix, S-shaped), not 64 and 128.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
let sh = Shape {
id: ShapeId(700),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
let sh = Shape {
id: ShapeId(701),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
for (x, want) in [(400, 96), (600, 175)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_shape_filled_with_the_background_shows_the_slide_background() {
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
// slide. A white panel covers a black-to-grey background; a shape on it with the background
// fill is a window onto that background.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
let panel = Shape {
id: ShapeId(710),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: none(),
..Default::default()
};
let window = Shape {
id: ShapeId(711),
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
fill: Some(Fill::Background),
line: none(),
..Default::default()
};
let mut p = deck_with(vec![panel, window]);
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
fill: Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
}),
});
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window");
assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270));
// Laid out on the slide: three quarters across the slide is half way from black to grey.
let v = img.pixel(720, 270)[0] as i32;
assert!((v - 100).abs() <= 6, "{v}");
}
#[test]
fn a_background_is_laid_over_white() {
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut p = deck_with(vec![]);
let media = p.add_media("bg.png", "image/png", png.into_inner());
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach");
let q = img.pixel(400, 230);
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 });
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
let g = img.pixel(20, 20);
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
}
#[test]
fn colour_glyphs_paint_their_layers() {
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
// square in its middle, U+E001 a square in the text colour.
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("\u{E000} \u{E001}");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(72.0);
props.font = Some("DeckCraft Colr Squares".into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
let share = blue as f64 / (red + blue) as f64;
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
// The text-colour glyph is as big as the other square.
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Ink rows (any pixel not white), grouped into the two lines.
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
// The darkest ink in the first rows of each line.
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
// ...and each line ends light.
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(count(&img, &green), 0);
// Glow: a green halo hugging the letters, needing the blur filter.
let glow =
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
}
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
without colour support.
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.colorLib.builder import buildCOLR, buildCPAL
def square(x0, y0, x1, y1):
pen = TTGlyphPen(None)
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()
return pen.glyph()
def empty():
return TTGlyphPen(None).glyph()
order = [".notdef", "space", "redblue", "ink", "big", "small"]
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
fb.setupGlyf({
".notdef": empty(),
"space": empty(),
"redblue": square(0, 0, 1000, 1000),
"ink": square(0, 0, 1000, 1000),
"big": square(0, 0, 1000, 1000),
"small": square(300, 300, 700, 700),
})
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
fb.setupHorizontalHeader(ascent=1000, descent=0)
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
fb.setupPost()
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
fb.save(sys.argv[1])
print("saved", sys.argv[1])
+5
View File
@@ -15,3 +15,8 @@ deckcraft-color = { workspace = true }
deckcraft-fonts = { workspace = true }
unicode-linebreak = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi = { workspace = true }
# The local clock for date fields (no clock on wasm: fields keep their saved text).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
chrono = { workspace = true }
+152
View File
@@ -0,0 +1,152 @@
//! Bidirectional text (UAX #9) for paragraph layout.
//!
//! The pipeline is the one the algorithm prescribes: embedding levels are resolved once for the
//! whole logical paragraph (base level from `a:pPr/@rtl`); shaping and line breaking happen in
//! logical order; then each line is reordered on its own ([`line_levels`] applies rule L1,
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
//! mirrorable characters in right-to-left items with their mirrored glyphs.
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
use unicode_bidi::{BidiInfo, Level, bidi_class};
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
/// explicit embeddings.)
fn needs_bidi(chars: &[char]) -> bool {
chars.iter().any(|c| matches!(bidi_class(*c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI))
}
/// Resolved embedding level of every character of a paragraph (before the line rules), with the
/// paragraph level forced to 1 when `rtl`.
pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
let base = u8::from(rtl);
if !rtl && !needs_bidi(chars) {
return vec![0; chars.len()];
}
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
// `levels` is per byte; take each character's first byte.
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
}
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
/// segment/paragraph separator.
fn l1_whitespace(c: BidiClass) -> bool {
matches!(c, WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF)
}
/// Rule L1 for one line: segment and paragraph separators, any whitespace run before them, and
/// whitespace at the end of the line take the paragraph level.
pub(crate) fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec<u8> {
let mut out: Vec<u8> = levels.to_vec();
out.resize(chars.len(), para_level);
// Walk backwards: `trailing` is true while every character after this one (up to the line end
// or a separator) is whitespace.
let mut trailing = true;
for (i, c) in chars.iter().enumerate().rev() {
let class = bidi_class(*c);
if matches!(class, S | B) {
if let Some(l) = out.get_mut(i) {
*l = para_level;
}
trailing = true;
} else if l1_whitespace(class) {
if trailing && let Some(l) = out.get_mut(i) {
*l = para_level;
}
} else {
trailing = false;
}
}
out
}
/// Rule L2: the visual order of a line's characters (indices into `levels`, left to right). From
/// the highest level down to the lowest odd level, every maximal run at that level or higher is
/// reversed.
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..levels.len()).collect();
let Some(&max) = levels.iter().max() else { return order };
let min_odd = levels.iter().copied().filter(|l| l % 2 == 1).min().unwrap_or(max.saturating_add(1));
let mut lvl = max;
while lvl >= min_odd && lvl > 0 {
let mut i = 0;
while i < order.len() {
let at = |k: usize| order.get(k).and_then(|&j| levels.get(j)).copied().unwrap_or(0);
if at(i) >= lvl {
let start = i;
while i < order.len() && at(i) >= lvl {
i += 1;
}
if let Some(run) = order.get_mut(start..i) {
run.reverse();
}
} else {
i += 1;
}
}
lvl -= 1;
}
order
}
#[cfg(test)]
mod tests {
use super::*;
fn lv(s: &str, rtl: bool) -> Vec<u8> {
let chars: Vec<char> = s.chars().collect();
levels(s, &chars, rtl)
}
#[test]
fn mixed_arabic_latin_numbers_and_brackets() {
let s = "سنة 2024 (Retrait) فقط";
let l = lv(s, true);
let chars: Vec<char> = s.chars().collect();
assert_eq!(l.len(), chars.len());
// Arabic letters at the paragraph level 1; European digits at 2; Latin at 2.
assert_eq!(l[0], 1);
assert_eq!(&l[4..8], &[2, 2, 2, 2]);
assert_eq!(&l[10..17], &[2; 7]);
assert_eq!(l[19], 1);
// Brackets around Latin inside an RTL paragraph resolve to the embedding direction (N0).
assert_eq!(l[9] % 2, 1, "( is right-to-left");
assert_eq!(l[17] % 2, 1, ") is right-to-left");
// Visual order, left to right: the Arabic words reversed, Latin and digits kept, the
// bracket characters swapped (their glyphs are mirrored back by the shaper, rule L4).
let order = visual_order(&line_levels(&chars, &l, 1));
let vis: String = order.iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "طقف )Retrait( 2024 ةنس");
}
#[test]
fn ltr_paragraph_fast_path_and_embedded_rtl() {
assert_eq!(lv("Hello 123", false), vec![0; 9]);
let s = "ab سن cd";
let chars: Vec<char> = s.chars().collect();
let l = lv(s, false);
assert_eq!(l, vec![0, 0, 0, 1, 1, 0, 0, 0]);
let vis: String = visual_order(&l).iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "ab نس cd");
// An RTL paragraph of Latin text: the text stays left to right inside it.
assert_eq!(lv("ab", true), vec![2, 2]);
}
#[test]
fn l1_resets_trailing_whitespace_and_tabs() {
let chars: Vec<char> = "ab\tcd ".chars().collect();
let l = line_levels(&chars, &[2, 2, 2, 2, 2, 2, 2], 1);
assert_eq!(l, vec![2, 2, 1, 2, 2, 1, 1]);
// Short level slices never panic.
assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]);
}
#[test]
fn visual_order_edge_cases() {
assert!(visual_order(&[]).is_empty());
assert_eq!(visual_order(&[0, 0]), vec![0, 1]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
// Numbers (level 2) inside RTL keep their order.
assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]);
assert_eq!(visual_order(&[125, 125]), vec![1, 0]);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment,
//! and the local clock.
//!
//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1`
//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list.
//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the
//! run's language; formats with month or day names, or AM/PM, are only computed for English, so
//! other languages keep the text the file was saved with.
/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateTime {
pub year: i32,
/// 1–12.
pub month: u32,
/// 1–31.
pub day: u32,
/// 0–23.
pub hour: u32,
pub minute: u32,
pub second: u32,
}
impl DateTime {
pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self {
DateTime { year, month, day, hour, minute, second }
}
/// The local date and time now (the user's time zone); `None` where there is no clock (wasm).
pub fn now_local() -> Option<DateTime> {
#[cfg(not(target_arch = "wasm32"))]
{
use chrono::{Datelike, Timelike};
let n = chrono::Local::now();
Some(DateTime::new(n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second()))
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
/// A real calendar date and time of day.
fn is_valid(&self) -> bool {
let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0);
let days = match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return false,
};
(1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61
}
/// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm).
fn weekday(&self) -> usize {
let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day));
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
(days + 4).rem_euclid(7) as usize
}
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/// Is field `kind` a date/time field?
pub fn is_date_field(kind: &str) -> bool {
kind.starts_with("datetime")
}
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
/// the format needs names in a language other than English: the field keeps its saved text.
pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option<String> {
if !now.is_valid() {
return None;
}
let lang = lang.map(str::trim).filter(|l| !l.is_empty()).unwrap_or("en-US").to_ascii_lowercase();
let english = lang == "en" || lang.starts_with("en-");
let month = MONTHS.get(now.month.checked_sub(1)? as usize)?;
let mon = month.get(..3)?;
let day = DAYS.get(now.weekday())?;
let (d, y) = (now.day, now.year);
let yy = y.rem_euclid(100);
let h12 = match now.hour % 12 {
0 => 12,
h => h,
};
let ampm = if now.hour < 12 { "AM" } else { "PM" };
let (mi, s) = (now.minute, now.second);
let short = short_date(&lang, now);
let named = |text: String| english.then_some(text);
match kind {
"datetime" | "datetimeFigureOut" | "datetime1" => Some(short),
"datetime2" => named(format!("{day}, {month} {d}, {y}")),
"datetime3" => named(format!("{d} {month} {y}")),
"datetime4" => named(format!("{month} {d}, {y}")),
"datetime5" => named(format!("{d}-{mon}-{yy:02}")),
"datetime6" => named(format!("{month} {yy:02}")),
"datetime7" => named(format!("{mon}-{yy:02}")),
"datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")),
"datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")),
"datetime10" => Some(format!("{}:{mi:02}", now.hour)),
"datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)),
"datetime12" => named(format!("{h12}:{mi:02} {ampm}")),
"datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")),
_ => None,
}
}
/// The short (numeric) date of language `lang` (lower case), US order unless the language is
/// known to write its dates another way.
fn short_date(lang: &str, t: DateTime) -> String {
let (y, m, d) = (t.year, t.month, t.day);
let primary = lang.split(['-', '_']).next().unwrap_or("");
match lang {
"en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"),
"en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"),
_ => {}
}
match primary {
"fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"),
"de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk" | "ro" => format!("{d:02}.{m:02}.{y}"),
"nl" => format!("{d}-{m}-{y}"),
"ja" | "zh" => format!("{y}/{m}/{d}"),
"ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"),
_ => format!("{m}/{d}/{y}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FRI: DateTime = DateTime { year: 2026, month: 10, day: 9, hour: 20, minute: 5, second: 7 };
#[test]
fn english_formats_of_every_type() {
let f = |k: &str| field_text(k, Some("en-US"), FRI);
assert_eq!(f("datetime1").as_deref(), Some("10/9/2026"));
assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026"));
assert_eq!(f("datetime3").as_deref(), Some("9 October 2026"));
assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026"));
assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26"));
assert_eq!(f("datetime6").as_deref(), Some("October 26"));
assert_eq!(f("datetime7").as_deref(), Some("Oct-26"));
assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM"));
assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM"));
assert_eq!(f("datetime10").as_deref(), Some("20:05"));
assert_eq!(f("datetime11").as_deref(), Some("20:05:07"));
assert_eq!(f("datetime12").as_deref(), Some("8:05 PM"));
assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM"));
assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026"));
assert_eq!(f("datetime").as_deref(), Some("10/9/2026"));
// No language: US English. Midnight and noon are 12 on the clock.
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(), Some("12:00 AM"));
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(), Some("12:30 PM"));
assert_eq!(field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(), Some("Saturday, January 1, 2000"));
}
#[test]
fn language_orders_numeric_dates_and_keeps_names_english_only() {
assert_eq!(field_text("datetime1", Some("en-GB"), FRI).as_deref(), Some("09/10/2026"));
assert_eq!(field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(), Some("09.10.2026"));
assert_eq!(field_text("datetime1", Some("ja-JP"), FRI).as_deref(), Some("2026/10/9"));
assert_eq!(field_text("datetime1", Some("sv-SE"), FRI).as_deref(), Some("2026-10-09"));
assert_eq!(field_text("datetime10", Some("fr-FR"), FRI).as_deref(), Some("20:05"));
// Month names in another language: not computed (the field keeps its saved text).
assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None);
assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None);
assert_eq!(field_text("datetime4", Some("en-GB"), FRI).as_deref(), Some("October 9, 2026"));
}
#[test]
fn unknown_types_and_impossible_dates_give_nothing() {
assert_eq!(field_text("slidenum", None, FRI), None);
assert_eq!(field_text("datetime14", None, FRI), None);
for bad in [
DateTime::new(2026, 0, 9, 0, 0, 0),
DateTime::new(2026, 13, 9, 0, 0, 0),
DateTime::new(2026, 2, 29, 0, 0, 0),
DateTime::new(2026, 4, 31, 0, 0, 0),
DateTime::new(2026, 1, 1, 24, 0, 0),
DateTime::new(i32::MIN, 1, 1, 0, 0, 0),
DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX),
] {
assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}");
}
assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum"));
}
#[test]
fn the_clock_reads_a_real_date() {
if let Some(now) = DateTime::now_local() {
assert!(field_text("datetime1", None, now).is_some(), "{now:?}");
}
}
}
+900 -201
View File
File diff suppressed because it is too large Load Diff
+481
View File
@@ -1,4 +1,5 @@
use super::*;
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
use deckcraft_model::{LayoutType, Presentation, Xfrm};
@@ -44,6 +45,20 @@ fn bullets_are_drawn_for_body_text() {
assert_eq!(l.para_lines.len(), 2);
}
#[test]
fn only_the_first_character_of_a_bullet_is_drawn() {
// "••" (seen in the wild) draws one dot in PowerPoint.
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
let (p, mut sh) = setup("One", 600.0, 300.0);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() });
let l = lay(&p, &sh, 600.0, 300.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
}
// The first grapheme cluster is kept whole.
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
}
#[test]
fn shrink_on_overflow() {
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
@@ -121,3 +136,469 @@ fn numbered_list_counts() {
let l = lay(&p, &sh, 400.0, 300.0);
assert!(l.runs.len() >= 6);
}
// ---------- Bidirectional text and Arabic typography ----------
const MIXED: &str = "سنة 2024 (Retrait) فقط";
/// A one-paragraph body with no bullet, `rtl` and `align` set.
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
let (p, mut sh) = setup("x", w, 300.0);
let t = sh.text.as_mut().unwrap();
t.paragraphs = vec![Paragraph { runs, ..Default::default() }];
let pp = &mut t.paragraphs[0].props;
pp.rtl = Some(rtl);
pp.align = Some(align);
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
(p, sh)
}
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
/// real Arabic glyphs are skipped without one.
fn arabic_face() -> Option<Arc<FontFace>> {
let db = FontDb::global();
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
f.covers('\u{0633}').then_some(f)
}
#[test]
fn rtl_mixed_string_is_reordered_per_line() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
assert!(line.rtl);
let n = MIXED.chars().count();
assert_eq!(line.edges.len(), n);
assert_eq!(line.caret_x.len(), n + 1);
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
assert!(mid(0) > mid(4), "first Arabic word right of the number");
assert!(mid(4) > mid(10), "number right of the Latin word");
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
assert!(mid(0) > mid(1) && mid(1) > mid(2));
assert!(mid(4) < mid(5) && mid(5) < mid(7));
assert!(mid(10) < mid(16));
// The brackets sit outside "Retrait": "(" (logically first) on its right.
assert!(mid(9) > mid(16) && mid(17) < mid(10));
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
let right_edge = l.inner.x1;
assert!((line.extent.1 - right_edge).abs() < 0.5, "{} vs {right_edge}", line.extent.1);
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
// Levels after L1: Arabic odd, digits and Latin even.
assert_eq!(line.levels[0], 1);
assert_eq!(line.levels[4], 2);
assert_eq!(line.levels[10], 2);
}
#[test]
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
pp.bullet = Some(Bullet::Char { char: "•".into() });
pp.margin_left = Some(36.0);
pp.indent = Some(-36.0);
let l = lay(&p, &sh, 400.0, 300.0);
let line = &l.lines[0];
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
let s_edge = l.inner.x1;
assert!((line.extent.1 - (s_edge - 36.0)).abs() < 0.5, "text right {} expected {}", line.extent.1, s_edge - 36.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
let bx = bullet.glyphs[0].1;
assert!(bx > line.extent.1 - 0.5, "bullet {bx} right of the text {}", line.extent.1);
assert!(bx < s_edge, "bullet inside the box");
// An LTR paragraph keeps the bullet on the left.
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
}
#[test]
fn rtl_left_and_center_alignment() {
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
assert!((l.lines[0].extent.0 - l.inner.x0).abs() < 0.5, "algn=l is flush left in RTL too");
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
let l = lay(&p, &sh, 400.0, 300.0);
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
}
#[test]
fn bidi_caret_stops_round_trip_through_hit_testing() {
for rtl in [true, false] {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, rtl, if rtl { Align::Right } else { Align::Left });
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
for (x, ch, aff) in line.visual_stops() {
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
assert!((cx - x).abs() < 0.01, "stop {ch} {aff:?}: caret {cx} vs stop {x}");
}
// Clicking just inside either side of each grapheme puts the caret on that side.
for (a, b) in line.graphemes() {
let Some((x0, x1)) = line.grapheme_box(a, b) else { continue };
if x1 - x0 < 1.0 {
continue;
}
let y = (line.top + line.bottom) / 2.0;
for x in [x0 + 0.2, x1 - 0.2] {
let (pos, aff) = l.hit_affinity(Point::new(x, y));
let (cx, ..) = l.caret_at(pos, aff).unwrap();
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
assert!((cx - side).abs() < 0.01, "rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}");
}
}
}
}
#[test]
fn visual_movement_walks_the_line_left_to_right() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let stops = l.lines[0].visual_stops();
let (x0, ch0, aff0) = stops[0];
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
let mut xs = vec![x0];
for _ in 0..200 {
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
if (np, na) == (pos, aff) {
break;
}
(pos, aff) = (np, na);
xs.push(l.caret_at(pos, aff).unwrap().0);
}
assert!(xs.windows(2).all(|w| w[1] > w[0]), "moving right always moves right: {xs:?}");
let right = l.lines[0].extent.1;
assert!((xs.last().unwrap() - right).abs() < 0.01, "reaches the right edge");
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
// Moving left from the right edge in an RTL paragraph is logically forward.
let (p1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, false, MoveMode::Visual);
assert_eq!(p1.ch, 1);
}
#[test]
fn logical_movement_steps_over_harakat() {
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
let (p, sh) = bidi_setup(vec![Run::new("\u{0628}\u{064E}\u{062A}")], 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let (n1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, true, MoveMode::Logical);
assert_eq!(n1.ch, 2);
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
assert_eq!(n2.ch, 0);
// No caret stop inside a grapheme.
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
}
#[test]
fn selection_is_the_union_of_visual_boxes() {
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 2, "{r:?}");
// The whole line is one box.
assert_eq!(l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 }).len(), 1);
}
#[test]
fn paint_only_style_changes_keep_arabic_joining() {
let Some(_) = arabic_face() else {
eprintln!("skipped: no Arabic face available");
return;
};
let word = "سنة";
let glyphs = |runs: Vec<Run>| {
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let mut g: Vec<(u32, f64)> = l.runs.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1))).collect();
g.sort_by(|a, b| a.1.total_cmp(&b.1));
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
};
let whole = glyphs(vec![Run::new(word)]);
let mut red = Run::new("سن");
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(Rgba::from_hex("C00000").unwrap())));
let split = glyphs(vec![red, Run::new("ة")]);
assert_eq!(whole, split, "a colour change inside a word keeps the joined forms");
// And the joined forms differ from the isolated letters.
let isolated: Vec<u32> = word.chars().map(|c| arabic_face().unwrap().glyph_for(c)).collect();
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
}
#[test]
fn just_low_stretches_arabic_with_kashida() {
if arabic_face().is_none() {
eprintln!("skipped: no Arabic face available");
return;
}
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
let tatweels = |l: &TextLayout| {
l.runs
.iter()
.map(|r| {
let tg = r.face.glyph_for('\u{0640}');
if tg == 0 { 0 } else { r.glyphs.iter().filter(|g| g.0 == tg).count() }
})
.sum::<usize>()
};
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
let l = lay(&p, &sh, 220.0, 600.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let w = l.inner.width();
assert!((first.extent.1 - first.extent.0 - w).abs() < 0.5, "first line fills the box: {:?} vs {w}", first.extent);
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
eprintln!("skipped: the Arabic face has no tatweel");
return;
}
assert!(tatweels(&l) > 0, "kashida drawn");
// The last line isn't stretched and sits at the start (right) edge.
let last = l.lines.last().unwrap();
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
// Plain justify uses spaces: no tatweel.
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
let l = lay(&p, &sh, 220.0, 600.0);
assert_eq!(tatweels(&l), 0);
}
#[test]
fn wrapped_rtl_lines_break_in_logical_order() {
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
let l = lay(&p, &sh, 160.0, 600.0);
assert!(l.lines.len() >= 2);
// Lines cover the text in logical order: the first line holds the first words.
assert_eq!(l.lines[0].start, 0);
for w in l.lines.windows(2) {
assert!(w[1].start >= w[0].end);
}
for line in &l.lines {
assert!(line.extent.0 >= l.inner.x0 - 0.5, "line fits: {:?}", line.extent);
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
}
// Upstream affinity at a wrap keeps the caret on the earlier line.
let end0 = l.lines[0].end;
assert_eq!(l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream), Some(0));
}
#[test]
fn hostile_bidi_input_never_panics() {
let nasty = format!("{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}", "\u{202B}".repeat(200), "\u{2067}".repeat(130));
for rtl in [true, false] {
for align in [Align::Left, Align::Right, Align::Center, Align::Justify, Align::JustLow, Align::Distributed] {
let (p, sh) = bidi_setup(vec![Run::new(nasty.clone()), Run::new(MIXED)], 60.0, rtl, align);
let l = lay(&p, &sh, 60.0, 60.0);
for line in &l.lines {
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
assert_eq!(line.edges.len(), line.end - line.start);
}
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
let mut pos = (Pos::default(), Affinity::Downstream);
for i in 0..50 {
pos = l.move_caret(pos.0, pos.1, i % 3 != 0, if i % 2 == 0 { MoveMode::Visual } else { MoveMode::Logical });
}
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
}
}
}
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
let mut run = Run::new(word);
run.props.size = Some(24.0);
run.props.baseline = baseline;
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
}
#[test]
fn superscript_and_subscript_advance_at_their_drawn_size() {
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
// letters apart ("s u p e r").
let normal = run_width("superscript", None);
for b in [Some(0.3), Some(-0.25)] {
let w = run_width("superscript", b);
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
}
}
#[test]
fn superscript_glyphs_sit_inside_their_cells() {
let mut run = Run::new("super");
run.props.size = Some(24.0);
run.props.baseline = Some(0.3);
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
let (x0, x1) = line.edges[*ci - line.start];
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
}
}
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
/// x, second line's text x).
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
let (p, mut sh) = setup(text, 300.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(bullet);
para.props.margin_left = Some(margin);
para.props.indent = Some(indent);
let l = lay(&p, &sh, 300.0, 300.0);
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
}
#[test]
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
let (bx, first, rest) = hanging(0.0, -36.0, num());
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
let (bx, first, rest) = hanging(18.0, -54.0, num());
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
// A character bullet behaves the same.
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
// Without a bullet the first line just starts at the edge.
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
}
#[test]
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
}
#[test]
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
for size in [18.0, 40.0] {
let mut run = Run::new("HHH");
run.props.size = Some(size);
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.autofit = Some(AutoFit::None);
let second = t.paragraphs[0].clone();
t.paragraphs.push(second.clone());
t.paragraphs.push(second);
for para in &mut t.paragraphs {
para.props.line_spacing = Some(Spacing::Pct(1.0));
para.props.space_before = Some(Spacing::Pts(0.0));
para.props.space_after = Some(Spacing::Pts(0.0));
}
let l = lay(&p, &sh, 2000.0, 600.0);
assert_eq!(l.lines.len(), 3);
// Each line is 1.2 × the size tall (the face is not compared across lines: another
// test's thread can load the family between them, see #252).
for line in &l.lines {
assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top);
}
let face = &l.runs[0].face;
let first = &l.lines[0];
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
}
}
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.inset_l = Some(0.0);
t.body.inset_r = Some(0.0);
set(&mut t.paragraphs[0].props);
let l = lay(&p, &sh, w, 600.0);
let chars: Vec<char> = text.chars().collect();
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
}
/// Width of `text` on one line.
fn one_line(text: &str) -> f64 {
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
let l = lay(&p, &sh, 5000.0, 300.0);
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
}
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
for sep in ["/", "\\"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
// A slash followed by a space still breaks, and hyphens still break.
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
#[test]
fn korean_wraps_between_words() {
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
let w = one_line("한국어 안녕하세요 반") + 0.5;
let lines = wrapped(text, w, |_| {});
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
// Japanese and Chinese still break between characters.
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
// edge instead of pulling the character before it onto the next line.
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
// Without it, "。" can't start a line, so "す。" moves down together.
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
}
#[test]
fn emoji_take_a_colour_face_when_one_is_installed() {
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
// even when the run's font has a plain glyph for some of them. Skipped without an installed
// colour emoji font.
let db = FontDb::global();
let probe = db.cascade(&['😀'], &[], "Regular");
if !probe.is_color() {
return;
}
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}
+4
View File
@@ -28,3 +28,7 @@ js-sys = "0.3"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
+105
View File
@@ -0,0 +1,105 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
//! A missing or malformed file gives empty tables (or no names) and a warning.
use std::fmt::Write as _;
use serde_json::Value;
fn main() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
})
.unwrap_or(Value::Null);
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
.unwrap_or_default();
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display());
}
}
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
println!("cargo::rerun-if-changed={}", path.display());
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
if let Err(e) = &r {
println!("cargo::warning={}: {e}; {fallback}", path.display());
}
r.ok()
}
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
fn name(v: Option<&str>) -> String {
match v.map(str::trim).filter(|s| !s.is_empty()) {
Some(x) => format!("Some({x:?})"),
None => "None".into(),
}
}
fn s(v: &Value, k: &str) -> String {
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
}
fn n(v: &Value, k: &str) -> u64 {
v.get(k).and_then(Value::as_u64).unwrap_or(0)
}
fn rows(json: &Value, k: &str) -> Vec<Value> {
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
}
fn generate(json: &Value, people: &toml::Table) -> String {
let mut o = String::new();
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
for c in rows(json, "contributors") {
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
if login.is_empty() {
continue;
}
// Names only from a verified, self-chosen people.toml entry.
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
let display = field("display_name")
.filter(|s| !s.trim().is_empty())
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
let _ = writeln!(
o,
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
name(display),
name(field("real_name")),
n(&c, "prs"),
n(&c, "commits"),
n(&c, "lines_added"),
n(&c, "lines_deleted"),
n(&c, "binary_added"),
n(&c, "binary_deleted"),
s(&c, "first_commit"),
s(&c, "last_commit"),
);
}
let _ = writeln!(o, "];");
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
for m in rows(json, "models") {
let _ = writeln!(
o,
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
s(&m, "company"),
s(&m, "model"),
s(&m, "version"),
n(&m, "commits"),
n(&m, "lines_added"),
n(&m, "lines_deleted"),
);
}
let _ = writeln!(o, "];");
o
}
+42 -11
View File
@@ -100,12 +100,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
let ppp = ui.ctx().pixels_per_point();
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
let now = ui.input(|i| i.time);
let gifs = match app.session.active() {
Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused),
_ => vec![],
};
crate::media::gif_repaint(ui.ctx(), &gifs);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
let st = app.session.active();
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale)
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
@@ -146,9 +153,37 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
media_bar(app, ui, xf);
gif_buttons(app, ui, xf, &gifs);
context_menu(app, &resp);
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
let Some(d) = app.session.active() else { return };
if d.selection.text.is_some() || app.session.tool.dragging() {
return;
}
let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None };
let pointer = ui.input(|i| i.pointer.hover_pos());
let mut toggle = None;
for g in gifs {
let r = screen_rect(xf, &g.rect);
if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) {
continue;
}
let b = crate::media::gif_button_rect(r);
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
if resp.clicked() {
toggle = Some(g.id);
}
}
if let Some(id) = toggle {
let _ = app.run("media.gifPlay", json!({"id": id}));
}
}
/// Screen rect of a slide-space box (rotation ignored).
fn screen_rect(xf: Xf, x: &Xfrm) -> Rect {
Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)))
@@ -379,14 +414,9 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
{
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let a = x.affine();
let rot = if l.rotation != 0.0 {
let c = l.inner.center().to_vec2();
deckcraft_geom::Affine::translate(c) * deckcraft_geom::Affine::rotate(l.rotation.to_radians()) * deckcraft_geom::Affine::translate(-c)
} else {
deckcraft_geom::Affine::IDENTITY
};
let m = a * rot;
// Where the text is drawn: flips don't mirror it, vertical text is turned.
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
let (s0, s1) = ts.ordered();
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
@@ -407,9 +437,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
{
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum();
let heights = deckcraft_engine::tools::table_rows(st, tb);
let y0: f64 = heights.iter().take(r).sum();
let x0: f64 = tb.cols.iter().take(c).sum();
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0));
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
let a = x.affine();
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
poly(painter, pts, Stroke::new(2.0, t.accent));
+9 -1
View File
@@ -73,6 +73,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"canvasScale": app.canvas_scale,
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
"home": app.home_open(),
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
"status": app.status.as_ref().map(|s| s.0.clone()),
@@ -232,7 +233,14 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"ui.dialog.open" => {
let Some(id) = s("id") else { return err("missing `id`") };
app.dialog = Some(crate::dialogs::Dialog::new(id));
let mut d = crate::dialogs::Dialog::new(id);
if let Some(fields) = p.get("fields").and_then(Value::as_object) {
for (k, v) in fields {
let v = v.as_str().map_or_else(|| v.to_string(), str::to_string);
d.fields.insert(k.clone(), v);
}
}
app.dialog = Some(d);
ok(Value::Null)
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(String::from) },
+399
View File
@@ -0,0 +1,399 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! read at run time.
use std::cmp::Ordering;
use egui::RichText;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Contributor {
pub login: &'static str,
pub display_name: Option<&'static str>,
pub real_name: Option<&'static str>,
pub prs: u64,
pub commits: u64,
pub lines_added: u64,
pub lines_deleted: u64,
pub binary_added: u64,
pub binary_deleted: u64,
/// ISO 8601 UTC; empty for someone credited only through a merged PR.
pub first_commit: &'static str,
pub last_commit: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Model {
pub company: &'static str,
pub model: &'static str,
pub version: &'static str,
pub commits: u64,
pub lines_added: u64,
pub lines_deleted: u64,
}
include!(concat!(env!("OUT_DIR"), "/credits.rs"));
/// Which name to show. Display and real names fall back to the `@username` when not given.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NameMode {
#[default]
Username,
DisplayName,
RealName,
}
impl NameMode {
pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName];
pub fn label(self) -> &'static str {
match self {
NameMode::Username => "Username",
NameMode::DisplayName => "Display name",
NameMode::RealName => "Real name",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SortKey {
Name,
Prs,
Commits,
LinesAdded,
LinesDeleted,
LinesDelta,
BinaryAdded,
BinaryDeleted,
#[default]
FirstCommit,
LastCommit,
}
impl SortKey {
pub const ALL: [SortKey; 10] = [
SortKey::Name,
SortKey::Prs,
SortKey::Commits,
SortKey::LinesAdded,
SortKey::LinesDeleted,
SortKey::LinesDelta,
SortKey::BinaryAdded,
SortKey::BinaryDeleted,
SortKey::FirstCommit,
SortKey::LastCommit,
];
/// Menu label and table header.
pub fn label(self) -> (&'static str, &'static str) {
match self {
SortKey::Name => ("Name (A–Z)", "Name"),
SortKey::Prs => ("Merged PRs", "PRs"),
SortKey::Commits => ("Commits", "Commits"),
SortKey::LinesAdded => ("Lines added", "+LOC"),
SortKey::LinesDeleted => ("Lines deleted", "−LOC"),
SortKey::LinesDelta => ("Line delta", "ΔLOC"),
SortKey::BinaryAdded => ("Binary assets added", "+Bin"),
SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"),
SortKey::FirstCommit => ("First commit", "First"),
SortKey::LastCommit => ("Last commit", "Last"),
}
}
/// Names and dates read naturally oldest/A first; counts read biggest first.
pub fn default_ascending(self) -> bool {
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
}
}
impl Contributor {
pub fn name(&self, mode: NameMode) -> String {
let chosen = match mode {
NameMode::Username => None,
NameMode::DisplayName => self.display_name,
NameMode::RealName => self.real_name,
};
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
}
pub fn lines_delta(&self) -> i128 {
i128::from(self.lines_added) - i128::from(self.lines_deleted)
}
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
self.commits,
group(self.lines_added),
group(self.lines_deleted),
signed(self.lines_delta()),
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
)
}
}
/// Alphabetical key: case-insensitive, ignoring the `@` of usernames.
fn name_key(name: &str) -> String {
name.trim_start_matches('@').to_lowercase()
}
fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering {
match key {
SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))),
SortKey::Prs => a.prs.cmp(&b.prs),
SortKey::Commits => a.commits.cmp(&b.commits),
SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added),
SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted),
SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()),
SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added),
SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted),
// A PR-only contributor (no commit date) sorts after everyone with a date.
SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)),
SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)),
}
}
fn dated(d: &str) -> (bool, &str) {
(d.is_empty(), d)
}
/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable.
pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> {
let mut v: Vec<&Contributor> = list.iter().collect();
v.sort_by(|a, b| {
let o = compare(a, b, mode, key);
let o = if ascending { o } else { o.reverse() };
o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login))
});
v
}
fn group(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
fn signed(n: i128) -> String {
let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX));
if n < 0 { format!("−{g}") } else { format!("+{g}") }
}
fn day(iso: &str) -> &str {
if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct View {
names: NameMode,
key: SortKey,
ascending: bool,
table: bool,
}
impl Default for View {
fn default() -> Self {
View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false }
}
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
v.ascending = !v.ascending;
}
ui.separator();
if ui.selectable_label(!v.table, "Grab bag").clicked() {
v.table = false;
}
if ui.selectable_label(v.table, "Table").clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
#[cfg(test)]
mod tests {
use super::*;
fn c(
login: &'static str,
display: Option<&'static str>,
real: Option<&'static str>,
added: u64,
deleted: u64,
first: &'static str,
) -> Contributor {
Contributor {
login,
display_name: display,
real_name: real,
prs: 0,
commits: 1,
lines_added: added,
lines_deleted: deleted,
binary_added: 0,
binary_deleted: 0,
first_commit: first,
last_commit: first,
}
}
fn people() -> [Contributor; 4] {
[
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
c("alice", None, None, 7, 1, ""),
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
]
}
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
v.iter().map(|c| c.login).collect()
}
#[test]
fn names_fall_back_to_the_username() {
let p = c("bob", Some("Bobby"), None, 0, 0, "");
assert_eq!(p.name(NameMode::Username), "@bob");
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
assert_eq!(p.name(NameMode::RealName), "@bob");
}
#[test]
fn alphabetical_ignores_at_and_case() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
}
#[test]
fn delta_and_dates() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
}
#[test]
fn formatting() {
assert_eq!(group(1234567), "1,234,567");
assert_eq!(group(12), "12");
assert_eq!(signed(-1000), "−1,000");
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
}
+72 -3
View File
@@ -693,7 +693,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ok || cancel
}
"about" => {
about(app, ui);
let tab = d.get("tab", "about");
ui.horizontal(|ui| {
for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] {
if ui.selectable_label(tab == k, l).clicked() {
d.fields.insert("tab".into(), k.into());
}
}
});
ui.separator();
match tab.as_str() {
"contributors" | "models" => {
// The credits need room for the table; the About tab keeps its compact size.
ui.set_min_size(vec2(640.0, 380.0));
ui.set_max_height(380.0);
if tab == "models" { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) }
}
_ => about(app, ui),
}
let (ok, _) = buttons(ui, "Close");
ok
}
@@ -830,14 +847,23 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
});
if app.home_open() {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
let _ = app.run("app.home", json!({"on": false}));
}
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
ui.add_space(40.0);
inset_wrapped(ui, |ui| {
for th in deckcraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
@@ -888,7 +914,50 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
/// would indent only the first row.
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
ui.horizontal_wrapped(add_contents);
});
}
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
#[test]
fn inset_wrapped_indents_every_row() {
let left = Cell::new(0.0_f32);
let tiles = RefCell::new(Vec::<Rect>::new());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
left.set(ui.max_rect().left());
tiles.borrow_mut().clear();
inset_wrapped(ui, |ui| {
for _ in 0..4 {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
tiles.borrow_mut().push(r);
}
});
});
harness.run();
let tiles = tiles.borrow().clone();
let first_top = tiles.first().map_or(0.0, |r| r.top());
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
let mut row_top = f32::NEG_INFINITY;
for r in &tiles {
if r.top() > row_top {
row_top = r.top();
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
}
}
}
}
+1 -1
View File
@@ -276,7 +276,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true);
egui::Popup::menu(&r).show(|ui| {
widgets::color_popup(&r).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, sc, None) {
let mut ng = g.clone();
ng.stops[sel].color = with_alpha(c, alpha_of(&s.color));
+103 -2
View File
@@ -8,6 +8,7 @@
pub mod canvas;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod fillui;
pub mod icons;
@@ -149,6 +150,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
("app.home", "Home", None, "{on?}"),
("app.about", "About DeckCraft", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
@@ -189,6 +191,9 @@ pub struct SlideApp {
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
/// The start screen is shown over the open presentations (Home), with the document count
/// and active index when it was opened: opening or creating a presentation leaves it.
pub home: Option<(usize, Option<usize>)>,
pending_urls: Vec<String>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
@@ -226,6 +231,7 @@ impl SlideApp {
slide_rect: None,
palette: None,
integrated_titlebar: false,
home: None,
pending_urls: vec![],
shot_token: 0,
queued_shots: vec![],
@@ -265,6 +271,15 @@ impl SlideApp {
r
}
fn docs_key(&self) -> (usize, Option<usize>) {
(self.session.documents().len(), self.session.active_index())
}
/// Home is open over a presentation (not just the start screen with nothing open).
pub fn home_open(&self) -> bool {
self.home.is_some() && self.session.active().is_some()
}
fn flag(&mut self, params: &Value, cur: bool) -> bool {
params.get("on").and_then(Value::as_bool).unwrap_or(!cur)
}
@@ -324,6 +339,10 @@ impl SlideApp {
self.show = None;
self.media.stop_owned(media::Owner::Show);
}
"app.home" => {
let on = self.flag(p, self.home.is_some());
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
}
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.palette" => self.palette = Some((String::new(), 0)),
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
@@ -592,6 +611,22 @@ impl SlideApp {
return;
}
let events = ctx.input(|i| i.events.clone());
if self.home_open() {
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
// presentation: typing, pasting or deleting would edit the hidden presentation.
for e in events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
if key == egui::Key::Escape {
self.home = None;
} else if mods.cmd || mods.alt {
menus::ui_shortcut(self, key, mods);
}
}
}
self.drain_requests();
return;
}
for e in events {
match e {
egui::Event::Text(t) => {
@@ -660,14 +695,17 @@ impl SlideApp {
self.perf.frame_ms = now_ms() - t0;
return;
}
if self.home.is_some_and(|k| k != self.docs_key()) {
self.home = None;
}
self.keyboard(&ctx);
ribbon::title_bar(self, ui);
if self.session.active().is_some() {
if self.session.active().is_some() && !self.home_open() {
ribbon::show(self, ui);
status::status_bar(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
if self.session.active().is_none() {
if self.session.active().is_none() || self.home_open() {
dialogs::start_screen(self, ui);
return;
}
@@ -838,3 +876,66 @@ pub fn now_ms() -> f64 {
fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn title_text(app: &SlideApp) -> String {
let d = app.session.active().unwrap();
let sh = &d.doc.slides[0].shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
/// #17: Home created a new blank presentation instead of going to the start screen.
#[test]
fn home_shows_the_start_screen_over_the_open_presentation() {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
// The app keeps requesting repaints, so step frames instead of run().
harness.run_steps(2);
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
harness.event(egui::Event::Text("a".into()));
harness.key_press(egui::Key::Backspace);
harness.run_steps(2);
assert_eq!(title_text(harness.state()), "");
assert!(!harness.state().session.active().unwrap().is_dirty());
harness.key_press(egui::Key::Escape);
harness.run_steps(2);
assert!(!harness.state().home_open());
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
harness.state_mut().run("app.home", json!({})).unwrap();
assert!(!harness.state().home_open());
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
harness.state_mut().run("file.new", json!({})).unwrap();
harness.run_steps(2);
assert!(!harness.state().home_open(), "a new presentation leaves Home");
assert_eq!(harness.state().session.documents().len(), 2);
}
#[test]
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());
app.run("app.home", json!({})).unwrap();
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
}
}
+68 -2
View File
@@ -1,7 +1,7 @@
//! Media playback in the UI host: the [`deckcraft_media::Player`] (audio out + clocks), video
//! feeds and their textures, the editor's media control bar, and the engine's `media.*` requests.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_engine::{MediaStatus, Session};
@@ -31,6 +31,18 @@ pub struct MediaHost {
videos: HashMap<ShapeId, Video>,
/// Probed durations by media bytes (for media imported without a probe).
durations: HashMap<usize, f64>,
/// Animated GIF clocks: when each started and, while paused, the time it holds.
gifs: HashMap<ShapeId, (f64, Option<f64>)>,
}
/// An animated GIF picture on a slide: its box, play time, frame and seconds to the next frame.
pub struct Gif {
pub id: ShapeId,
pub rect: deckcraft_geom::Xfrm,
pub t: f64,
pub frame: usize,
pub next: f64,
pub paused: bool,
}
impl Default for MediaHost {
@@ -45,7 +57,30 @@ fn key(id: ShapeId) -> u64 {
impl MediaHost {
pub fn new(out: Option<Box<dyn AudioOut>>) -> MediaHost {
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new() }
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() }
}
/// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was
/// first shown; a paused one holds its time and resumes from it.
pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet<ShapeId>) -> Vec<Gif> {
let Some(slide) = doc.slides.get(index) else { return vec![] };
let mut out = vec![];
for sh in &slide.shapes {
let (ShapeKind::Picture { fill }, Some(rect)) = (&sh.kind, sh.xfrm) else { continue };
let Some(item) = doc.media(fill.media).filter(|m| m.data.starts_with(b"GIF8")) else { continue };
let p = paused.contains(&sh.id);
let c = self.gifs.entry(sh.id).or_insert((now, None));
if p && c.1.is_none() {
c.1 = Some(now - c.0);
} else if !p && let Some(held) = c.1.take() {
c.0 = now - held;
}
let t = c.1.unwrap_or(now - c.0);
if let Some((frame, next)) = deckcraft_render::gif_frame(&item.data, &fill.adjust, t) {
out.push(Gif { id: sh.id, rect, t, frame, next, paused: p });
}
}
out
}
/// Replace the audio output (the host injects cpal after start-up).
@@ -261,6 +296,37 @@ pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect:
true
}
/// Changes whenever a GIF shows another frame (part of the slide texture's cache key).
pub fn gif_key(gifs: &[Gif]) -> u64 {
gifs.iter().fold(0, |h: u64, g| (h ^ (((g.id.0 as u64) << 32) | g.frame as u64)).wrapping_mul(1099511628211))
}
/// Come back when the next frame of a playing GIF is due.
pub fn gif_repaint(ctx: &egui::Context, gifs: &[Gif]) {
if let Some(next) = gifs.iter().filter(|g| !g.paused).map(|g| g.next).reduce(f64::min) {
ctx.request_repaint_after(std::time::Duration::from_millis((next * 1000.0).clamp(5.0, 1000.0) as u64));
}
}
/// The play/pause button in the bottom-left corner of a GIF's box `r` (PowerPoint puts it there).
pub fn gif_button_rect(r: Rect) -> Rect {
let s = 24.0f32.min(r.width() - 8.0).min(r.height() - 8.0).max(12.0);
Rect::from_min_size(pos2(r.min.x + 6.0, r.max.y - 6.0 - s), vec2(s, s))
}
pub fn paint_gif_button(painter: &egui::Painter, b: Rect, paused: bool, hot: bool) {
painter.circle_filled(b.center(), b.width() / 2.0, Color32::from_black_alpha(if hot { 210 } else { 150 }));
let r = b.shrink(b.width() * 0.3);
let white = Color32::from_gray(235);
if paused {
icons::paint(painter, r, Icon::Play, white, false);
} else {
let w = r.width() * 0.3;
painter.rect_filled(Rect::from_min_size(r.min, vec2(w, r.height())), CornerRadius::ZERO, white);
painter.rect_filled(Rect::from_min_size(pos2(r.max.x - w, r.min.y), vec2(w, r.height())), CornerRadius::ZERO, white);
}
}
/// What the control bar asks for.
pub enum BarAction {
Toggle,
+2
View File
@@ -210,6 +210,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Center", "format.alignCenter"),
("Align Right", "format.alignRight"),
("Justify", "format.justify"),
("-", ""),
("Right-to-Left Text Direction", "format.rtl"),
],
),
(
+3 -3
View File
@@ -75,7 +75,7 @@ fn color_button(
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = widgets::drop_button(ui, "Color", vec2(70.0, 22.0), true);
egui::Popup::menu(&r).show(|ui| {
widgets::color_popup(&r).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, none_label) {
let (id, p) = on_pick(c);
run(app, &id, p);
@@ -391,7 +391,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.label("Color");
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
egui::Popup::menu(&r).show(|ui| {
widgets::color_popup(&r).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
run(app, "design.background", json!({"color": cref_param(&c)}));
}
@@ -417,7 +417,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.label(label);
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
egui::Popup::menu(&r).show(|ui| {
widgets::color_popup(&r).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) };
run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}}));
+28 -11
View File
@@ -47,8 +47,8 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
child.spacing_mut().item_spacing.x = 2.0;
let has_doc = app.session.active().is_some();
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
if icon_toggle(&mut child, Icon::Home, "Home", true, false).clicked() {
let _ = app.run("file.new", json!({}));
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
let _ = app.run("app.home", json!({}));
}
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
app.save();
@@ -473,7 +473,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
if m.clicked() {
run(app, "format.highlight", json!({"color": "#FFFF00"}));
}
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) {
run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())}));
}
@@ -482,7 +482,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
if m.clicked() {
run(app, "format.color", json!({"color": "#C00000"}));
}
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
run(app, "format.color", json!({"color": cref_param(&c)}));
}
@@ -743,7 +743,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
run(app, "shape.fill", json!({"color": "accent1"}));
}
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
match c {
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
@@ -757,7 +757,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
run(app, "shape.line", json!({"color": "tx1", "width": 1}));
}
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
@@ -778,6 +778,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
if ui.button(format!("{w} pt")).clicked() {
run(app, "shape.line", json!({"width": w}));
ui.close();
}
}
});
@@ -793,6 +794,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
] {
if ui.button(l).clicked() {
run(app, "shape.line", json!({"dash": d}));
ui.close();
}
}
});
@@ -807,6 +809,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
] {
if ui.button(l).clicked() {
run(app, "shape.line", json!({"head": h, "tail": tl}));
ui.close();
}
}
});
@@ -1624,6 +1627,19 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
}
}
});
let on = app.session.anim_painter.is_some();
let en = enabled(app, "animation.painter");
let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on);
let r = r.on_hover_text("Animation Painter (double-click to keep it on)");
if r.double_clicked() {
run(app, "animation.painter", json!({"sticky": true}));
} else if r.clicked() {
if on {
app.session.anim_painter = None;
} else {
run(app, "animation.painter", json!({}));
}
}
});
group(ui, |ui| {
rows(ui, |ui| {
@@ -1881,7 +1897,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
run(app, "shape.fill", json!({"color": "accent1"}));
}
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
match c {
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
@@ -1890,12 +1906,13 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
}
if ui.button("Gradient…").clicked() {
let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"}));
ui.close();
}
});
}
let (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
@@ -1907,7 +1924,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
rows(ui, |ui| {
let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
run(app, "format.color", json!({"color": cref_param(&c)}));
}
@@ -1915,7 +1932,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
}
let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) {
run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))}));
}
@@ -2183,7 +2200,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
if m.clicked() {
run(app, "table.cellFill", json!({"color": "accent1"}));
}
egui::Popup::menu(&a).show(|ui| {
widgets::color_popup(&a).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
match c {
Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})),
+165 -7
View File
@@ -1,7 +1,8 @@
//! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`),
//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings.
use deckcraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline};
use deckcraft_model::text::Hyperlink;
use deckcraft_model::{Presentation, ShapeId};
use deckcraft_render::{ParaState, RenderOpts, ShapeState};
use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
@@ -37,6 +38,10 @@ pub struct Show {
pub ended: bool,
/// The slide whose media was last started (auto-play runs once per visit).
media_slide: Option<usize>,
/// The slide shown before the current one (the Last Slide Viewed action).
last_viewed: Option<usize>,
/// Click areas of hyperlinks and shape actions on slide `.0`, in slide points.
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>,
}
#[derive(Clone, PartialEq)]
@@ -47,10 +52,15 @@ struct Key {
doc: usize,
/// Media hidden while not playing.
hidden: Vec<ShapeId>,
/// The frames animated GIFs show.
gif: u64,
}
struct Transition {
old: Option<TextureHandle>,
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
from: Option<usize>,
morph: Option<TextureHandle>,
start: f64,
kind: String,
option: String,
@@ -83,6 +93,8 @@ impl Show {
last_move: 0.0,
ended: false,
media_slide: None,
last_viewed: None,
links: None,
}
}
@@ -118,9 +130,15 @@ impl Show {
.and_then(|s| s.transition.as_ref())
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
.unwrap_or(("none".into(), String::new(), 0.0));
let old = self.tex.as_ref().map(|(_, t)| t.clone());
// Take the old slide's texture rather than cloning its handle: the new slide is
// otherwise rendered into the same texture, and the transition blends the new
// slide with itself (#33).
let (from, old) = self.tex.take().map(|(k, t)| (k.slide, t)).unzip();
if from != Some(i) {
self.last_viewed = from;
}
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
Some(Transition { old, start: now, kind, option, dur })
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
} else {
None
};
@@ -273,9 +291,22 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
_ => {}
}
}
let media_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
if let Some((id, clip)) = media_click {
let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx);
let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None };
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None };
if let Some(id) = gif_click {
let _ = app.run("media.gifPlay", json!({"id": id}));
} else if let Some((id, clip)) = media_click {
app.media.toggle(&doc, id, &clip, crate::media::Owner::Show);
} else if clicked && let Some(l) = &link {
let last = show.last_viewed;
match show.state.follow(&doc, &l.action, last) {
LinkJump::Show(a) => actions.push(a),
LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
LinkJump::Exit => keep = false,
LinkJump::None => {}
}
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
if show.ended {
keep = false;
@@ -288,6 +319,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
if secondary && !show.ended {
actions.push(show.state.prev(&doc));
}
// Apply input first: a step started by this click must restart the clock before it is read.
for a in actions.drain(..) {
if !show.apply(a, &doc, now) {
keep = false;
}
}
// Step clock.
let (step, playing) = (show.state.step, show.state.playing);
let t_step = if playing { now - show.step_start } else { 0.0 };
@@ -345,7 +382,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
})
.map(|m| m.0)
.collect();
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone() };
let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
crate::media::gif_repaint(&ctx, &gifs);
let gif = crate::media::gif_key(&gifs);
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif };
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
show.tex.as_ref().map(|(_, t)| t.clone())
} else {
@@ -367,6 +408,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
scale: size.0 as f64 / doc.slide_size.width.max(1.0),
size: Some(size),
state: Some(&f),
gif_times: &times,
threads,
..Default::default()
},
@@ -383,7 +425,52 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
Some(tex)
};
let mut drew = false;
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur);
let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None };
let morph = match (&show.trans, tl) {
(Some(tr), Some(tl)) => {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
let hidden_b = |id: ShapeId| {
let st = state_for(&tl, id, 0, 0.0);
!st.visible || st.opacity <= 0.0 || hidden.contains(&id)
};
tr.from
.and_then(|i| doc.slides.get(i).map(|a| (i, a)))
.zip(doc.slides.get(idx))
.map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b)))
}
_ => None,
};
if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
let threads =
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
let opts =
RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };
let text: Vec<deckcraft_render::TextMorph> = m
.text
.iter()
.map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
.collect();
let paths: Vec<deckcraft_render::PathMorph> = m
.paths
.iter()
.map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })
.collect();
let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts);
let ci = crate::textures::to_color_image(&img, false);
let frame = match tr.morph.take() {
Some(mut h) => {
h.set(ci, egui::TextureOptions::LINEAR);
h
}
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
};
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
tr.morph = Some(frame);
drew = true;
ctx.request_repaint();
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
if p < 1.0 {
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {
@@ -420,6 +507,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
};
crate::media::paint_frame(&painter, &app.media, *id, r);
}
// Play/pause on the animated GIF under the pointer.
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && now - show.last_move < 2.5) {
for g in &gifs {
let r = on_screen(srect, k, &g.rect);
if r.contains(p) {
let b = crate::media::gif_button_rect(r);
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
}
}
}
}
if animating {
ctx.request_repaint();
@@ -440,6 +537,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height()));
}
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else if link.is_some() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
} else if now - show.last_move > 2.5 && !show.reading {
ctx.set_cursor_icon(egui::CursorIcon::None);
ctx.request_repaint_after(std::time::Duration::from_millis(500));
@@ -584,6 +683,39 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O
})
}
/// The link under the pointer: the topmost shape action or text hyperlink on the current slide
/// (a text link wins over its shape's action).
fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option<Hyperlink> {
let p = ctx.input(|i| i.pointer.hover_pos())?;
let full = ui.max_rect();
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
let srect = fit(avail, doc);
let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64;
let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k);
let i = show.state.slide;
if show.links.as_ref().is_none_or(|(s, _)| *s != i) {
show.links = Some((i, deckcraft_render::place_slide(doc, i).links));
}
let (_, links) = show.links.as_ref()?;
links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone())
}
/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point.
fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect {
Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k))
}
/// The animated GIF whose play/pause button is under the pointer on the shown slide.
fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option<ShapeId> {
let p = ctx.input(|i| i.pointer.interact_pos())?;
let full = ui.max_rect();
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
let srect = fit(avail, doc);
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
}
impl Show {
fn reading_bar_hit(&self, ctx: &egui::Context) -> bool {
// Clicks on the on-screen controls must not also advance.
@@ -686,3 +818,29 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn a_transition_keeps_the_old_slide_in_its_own_texture() {
// #33: the old slide's handle was cloned, so the new slide was rendered into the same
// texture and every transition blended the new slide with itself.
let mut s = deckcraft_engine::Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap();
let doc = s.active().unwrap().doc.clone();
let ctx = egui::Context::default();
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
let mut show = Show::new(&doc, 0, false, false);
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone()));
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
let tr = show.trans.as_ref().expect("the fade plays");
assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id()));
assert_eq!(tr.from, Some(0));
assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one");
}
}
+5 -3
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::{Master, Presentation, Slide};
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
use deckcraft_render::{Image, RenderOpts};
use egui::{ColorImage, TextureHandle, TextureOptions};
@@ -60,7 +60,7 @@ fn threads() -> u16 {
}
impl Textures {
/// The canvas texture for slide `index` at `size` pixels.
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
pub fn slide(
&mut self,
ctx: &egui::Context,
@@ -70,6 +70,7 @@ impl Textures {
edit: bool,
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
@@ -87,7 +88,8 @@ impl Textures {
}
let t0 = crate::now_ms();
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() });
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
let img = deckcraft_render::render_slide(p, index, &opts);
self.last_render_ms = crate::now_ms() - t0;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {

Some files were not shown because too many files have changed in this diff Show More