Compare commits

...
259 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ee39ff6781 Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:14:22 -05:00
Brandon ThomasandClaude Opus 5.5 bbd579813f packaging(flatpak): flatpak-oci.sh sources common.sh, not the absent env.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:14:20 -05:00
Brandon ThomasandClaude Opus 5.5 eaf8d1ce3f Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:23 -05:00
Dylan M. Taylor f2f0c4373d docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:03 -05:00
xmaks82andМагда М.Г. 5fb694d991 Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint) (#924)
* Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint)

Unicode line breaking allows a break after ?, !, } and + inside a word,
so a long URL broke after its '?' and after each '+' of a query and left
the rest of the line empty. Measured in PowerPoint 2016 for Windows with
latinLnBrk off (its default), none of them is a break: the word fills the
line and breaks where it no longer fits, as PowerPoint already does after
/ and \ (break_opportunities). The same synthetic slide now wraps line for
line as PowerPoint's PDF does, a Yandex search URL included.

* Text tests: the first line doesn't end at the separator

* Text tests: check the line is full, not that it avoids ending at a separator

* Keep the Unicode break after ?, !, / outside Latin words

The PowerPoint no-break rule only holds inside a word: before an ideograph
(日本!語) or punctuation the Unicode break stays, as CodeRabbit pointed out.

* Only letters and digits continue a word for the no-break rule

AL and AI also hold symbols such as & and ×, so a!&b kept no break after !.
is_word_char now also requires c.is_alphanumeric() (CodeRabbit).

* Test that Latin before CJK and a separator before a space still break

---------

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
2026-11-01 10:54:32 +01:00
Dylan M. Taylor e690829543 ci: grant packages write permission to release workflow 2026-10-31 23:43:08 -04:00
Dylan M. Taylor 82573331a8 packaging(flatpak): add OCI image build and GHCR publishing helper (#1625) 2026-10-31 18:52:58 -04:00
Dylan M. Taylor a3fbf02952 Merge pull request #1592 from aiongg/docs-tidy
docs: tidy agent, contributor and roadmap docs
2026-10-31 01:06:52 -04:00
Dylan Taylor 92bf7fc67d fix(xtask): update SHARED_REV for craft-libs update 2026-10-31 01:05:27 -04:00
Dylan M. Taylor 0ed4b394ac Merge pull request #1599 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:19 -04:00
Brandon Thomas f7a7fd7f5f Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:59:38 -04:00
Brandon ThomasandClaude Opus 5.5 800ffeaa59 Motion paths: check raw path strings without deckcraft-anim, which the engine only has as a dev-dependency
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:38 -04:00
Brandon Thomas d080a23dc0 Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:56:36 -04:00
Brandon Thomas 243c1a269f Merge pull request #1582
Ogg: bound the work and the warnings of damage recovery
2026-10-31 00:56:35 -04:00
Brandon Thomas 06a1ca4672 Merge pull request #1521
Slideshow click targets follow animated shapes
2026-10-31 00:56:35 -04:00
Brandon Thomas 40728b5362 Merge pull request #1591
Preserve gradient flip mode in PPTX

# Conflicts:
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:56:34 -04:00
Brandon ThomasandClaude Opus 5.5 d2504e5f8b Motion paths: a raw path string must parse to at least two finite points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:56:17 -04:00
Brandon Thomas d92874f541 Merge pull request #1574
Add read-only slide action inspection
2026-10-31 00:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 6a34f13808 Ogg: the budget-spent warning goes last, after the capped warnings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:52 -04:00
Brandon ThomasandClaude Opus 5.5 6058fc7882 gaps.md: drop the stray 10-28 click-target row from among the 10-13 rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:38 -04:00
Brandon ThomasandClaude Opus 5.5 04b0958bee Gradient flip: drop the blank lines before the new flip fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:16 -04:00
Brandon ThomasandClaude Opus 5.5 acf054852c slide.actions refuses a bad index instead of using the selected slide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:53:14 -04:00
Brandon Thomas 861879f8ee Merge pull request #1519
Use private directories for print previews
2026-10-31 00:51:49 -04:00
Brandon Thomas e626625d40 Merge pull request #1514
Reject picture slices in codec configuration records
2026-10-31 00:51:48 -04:00
Brandon Thomas 4a0f48a04c Merge pull request #1590
Preserve inset line alignment in PPTX

# Conflicts:
#	crates/model/src/style.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:48 -04:00
Brandon Thomas 8f6e1b420a Merge pull request #1589
Preserve bullet Follow Text settings

# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:37 -04:00
Brandon Thomas c0db6ebeba Merge pull request #1585
Preserve explicit line miter limits
2026-10-31 00:51:37 -04:00
Brandon Thomas a667e13a03 Merge pull request #1594
Parse uppercase exponent in custom geometry path numbers
2026-10-31 00:51:36 -04:00
Brandon ThomasandClaude Opus 5.5 3bf35c801f Custom geometry: 'E' is an exponent only when digits follow, so 'M 0 0E' keeps its end command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:51:36 -04:00
Brandon Thomas 47eb6732fa Merge pull request #1581
contributors: add names for marshiyar
2026-10-31 00:49:38 -04:00
Brandon Thomas 8a2eccc863 Merge pull request #1583
MCP: insert_chart takes x, y, w and h
2026-10-31 00:49:38 -04:00
Brandon Thomas fb05eaa30f Merge pull request #1490
Make layout/master placeholder formatting reach slides

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-31 00:49:37 -04:00
Brandon Thomas 231a52438c Merge pull request #1587
Reject invalid colour in format.highlight instead of clearing it
2026-10-31 00:49:36 -04:00
Brandon Thomas a00b7e8a51 Merge pull request #1577
Reject invalid index in transition commands instead of using selection
2026-10-31 00:49:36 -04:00
aiongg 9678ed7d7f updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:16 +00:00
NetterSilverandClaude Haiku 5.5 1de7b44a8d Parse uppercase exponent in custom geometry path numbers
Found while testing crates/pptx/src/custgeom.rs:334

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 05:33:56 +01:00
Harrison Booth 5a235590dd Move gradient editor test module to file end 2026-10-30 23:26:55 -05:00
aiongg a96dcdbe8f docs: tidy wording 2026-10-31 04:25:45 +00:00
Harrison Booth 0e3f4cd353 Preserve explicit bullets over follow-text resets 2026-10-30 23:20:43 -05:00
Harrison Booth 72c8621358 Keep gradient flip when editing a fill 2026-10-30 23:17:32 -05:00
aiongg cccc9064ca docs: tidy agent, contributor and roadmap docs 2026-10-31 04:17:24 +00:00
Harrison Booth 1de0101ced Honor miter percentage schema bounds 2026-10-30 23:11:12 -05:00
Harrison Booth f5c2aca644 Preserve bullet follow-text inheritance 2026-10-30 23:05:16 -05:00
Harrison Booth 4408ff6153 Restrict line alignment to schema values 2026-10-30 23:00:20 -05:00
Harrison Booth ad97533aec Preserve gradient flip mode in PPTX 2026-10-30 22:57:14 -05:00
Harrison Booth 727b682d17 Preserve inset line alignment in PPTX 2026-10-30 22:48:31 -05:00
Harrison Booth 8770fb90c3 Preserve bullet follow-text resets in PPTX 2026-10-30 22:45:55 -05:00
NetterSilverandClaude Haiku 5.5 9a716be59e Reject invalid colour in format.highlight instead of clearing it
Found while testing crates/engine/src/cmd/format/font.rs:228-231 (highlight), color_param at crates/engine/src/cmd.rs:381

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 04:41:09 +01:00
Harrison Booth 479c3cc5ea Preserve explicit miter limits in PPTX 2026-10-30 22:24:08 -05:00
Brandon ThomasandClaude Opus 5.5 4ce2944630 Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:21:58 -04:00
Brandon ThomasandClaude Opus 5.5 255df314da FreeBSD packaging: compare ELF header bytes without od's column padding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:21:55 -04:00
marshiyar b7482dd392 engine: animation.add and animation.set take the points of a custom motion path
What was wrong: animation.add ignored a `path` parameter, so a customPath
animation always got the default straight path of a quarter of the slide
height, and no command could set or change the points of one.

What changed: animation.add, animation.set and animation.options accept `path`
for a motion path: either points [[x, y], ...] in slide points, as offsets from
where the shape sits (converted to the stored slide-fraction path; `closed`
returns to the first point), or the OOXML path string animation.get answers.
At most 1000 points, finite numbers within 1e6 points, and strings of at most
16 KiB made of path commands and numbers are accepted; anything else is
refused before the deck changes, as is `path` on an animation that is not a
motion path. animation.add answers the stored path.

How it was tested: three new tests in cmd/animation.rs: points become the
expected path (add, set with `closed`, back to the default without `path`), a
raw string is kept and animation.options replaces it, and malformed paths are
refused with nothing added. The animation tests pass, and clippy is clean with
-D warnings.

Fixes #1576
2026-10-30 20:13:05 -07:00
marshiyar e83f964077 mcp: insert_chart takes x, y, w and h
What was wrong: the insert_chart tool had no x, y, w or h in its schema, so the
server refused them as unknown arguments, while insert_picture and insert_table
take them. A chart could only be placed at the default box, [144, 81, 672, 378]
on a 960x540 slide, and then had to be moved with a second command.

What changed: insert_chart declares x, y, w and h and folds them into the
engine's `rect` when all four are given, as insert_picture and insert_table do.
Without all four the chart keeps its default place.

How it was tested: insert_chart_takes_a_box failed before the change (the
arguments were refused) and passes after it. It checks the box of the placed
chart, that a partial box leaves the default place, and that an extreme box is
answered without a panic. The rest of the deckcraft-mcp tests pass, and clippy
is clean with -D warnings.

Fixes #1151
2026-10-30 20:13:00 -07:00
Brandon ThomasandClaude Opus 5.5 eaeb06c655 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:19 -04:00
marshiyar ede7a7ead7 ogg: bound damage recovery work and the warnings list
What was wrong: when a page failed its CRC the demuxer had already read the
whole page (up to about 64 KiB), then moved on by one byte and tried again.
Nothing was charged for it, and only the scan for the next capture pattern had
a budget, which a candidate that matches "OggS" at the current position never
reaches. A file made of capture patterns 64 bytes apart, each claiming a full
page, made a 1 MB file read about 957 MB and compute a CRC over all of it. The
warnings list also grew with every damaged page, without limit.

What changed: the per-file recovery budget (32 MiB, the scan's existing one) is
now also charged with the length of every page read only to find its CRC wrong.
When it is spent the pages found so far are returned with a warning saying the
rest was not searched. At most 256 warnings are kept, plus one saying the rest
were dropped.

How it was tested: two new tests failed before the fix and pass after it, a
counting ByteSource over 1 MB of dense fake pages (bytes read stay within the
budget; before, 957 MB were read) and 100,000 bad-CRC pages (before, 199,999
warnings). A file of good pages is not charged and has no warnings. The other
ogg tests still pass, and clippy is clean with -D warnings.

Completes the Ogg part of storytold/filmcraft#2794
2026-10-30 18:27:08 -07:00
marshiyar 6113f4e1a2 contributors: add names for marshiyar 2026-10-30 17:57:10 -07:00
Dylan M. Taylor 4c1d77ddce fix(flatpak): allow reading launcher version pins (#1575)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:24 -04:00
NetterSilverandClaude Haiku 5.5 443f503407 Reject invalid index in transition commands instead of using selection
Found while testing crates/engine/src/cmd/transition.rs:47-54 (slide_indices, via usize_param)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:16:32 +00:00
Harrison Booth ee4bdbcd7d fix: initialize slide action test run props 2026-10-30 17:23:00 -05:00
NetterSilverandClaude Sonnet 5.5 adaa4e6fa1 fix: keep per-level formatting when formatting layout/master placeholders
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:22:33 +01:00
NetterSilverandClaude Opus 5.5 446bc528ad Make layout/master placeholder formatting reach slides
Fixes #1487

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:17:36 +01:00
Harrison Booth 3c068df628 test(h264): complete picture slice fixture 2026-10-30 17:13:38 -05:00
Harrison Booth f8165751e5 Merge origin/main into dc-slide-actions-list 2026-10-30 17:12:14 -05:00
Harrison Booth 7190c85860 test(h264): avoid singleton fixture loop 2026-10-30 17:06:36 -05:00
Harrison Booth ddd39249ba test(codec): strengthen configuration slice regressions 2026-10-30 17:03:36 -05:00
Harrison Booth b4c1905d78 dc-fix-1519: address print preview review 2026-10-30 16:51:34 -05:00
Harrison Booth 2c11865b2d codec: accept hvcC SEI arrays without allocation 2026-10-30 16:49:21 -05:00
Harrison Booth b92a7ddbd5 Merge remote-tracking branch 'origin/main' into dc-fix-1514 2026-10-30 16:37:40 -05:00
Harrison Booth 0768e95591 Print preview: merge origin/main 2026-10-30 16:36:30 -05:00
Harrison Booth ef6fd29928 DC-1521: hit-test each shape animation independently 2026-10-30 16:00:43 -05:00
Harrison Booth a3449185ac DC-1521: merge origin/main 2026-10-30 15:59:33 -05:00
Brandon Thomas c4221b1a7c Merge pull request #1371
Slide show: render the Presenter View's next-slide preview off the UI thread
2026-10-30 16:08:21 -04:00
Brandon Thomas 46f23229ab Merge pull request #1510
Clipboard HTML export stays within its size cap
2026-10-30 16:08:21 -04:00
Brandon Thomas ae08bbe62d Merge pull request #1546
Accessibility: low contrast is checked across all text runs
2026-10-30 16:08:20 -04:00
Brandon Thomas 7b46958fa2 Merge pull request #1536
Suggest corrections for misspelt all-caps words
2026-10-30 16:08:19 -04:00
Brandon Thomas c7b8a11895 Merge pull request #1406
Find zip entries stored with backslash separators in pptx packages
2026-10-30 16:08:19 -04:00
Brandon Thomas 81eff036d2 Merge pull request #1439
Duplicate names the copy like PowerPoint: next automatic name, chosen names kept
2026-10-30 16:08:18 -04:00
Brandon ThomasandClaude Opus 5.5 bcbdb76bfd Slide show test: clear the textures of the lone presenter frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:06:58 -04:00
Brandon Thomas 3dc66ac1a5 Merge pull request #1417
Text: a space after a symbol-font character keeps the run's font
2026-10-30 16:05:36 -04:00
Brandon Thomas 1b5d860dfc Merge pull request #1449
Format Shape Effects: preset tiles for Shadow, Glow and Soft Edges, shadow Size
2026-10-30 16:05:36 -04:00
Brandon Thomas 3e8a9193bc Merge pull request #1447
Format Shape: Scale Height/Width and a From origin in Size & Properties

# Conflicts:
#	crates/ui-egui/src/panels/format_shape.rs
2026-10-30 16:05:35 -04:00
Brandon Thomas cee1d015ec Merge pull request #1438
Animations: the Add Animation menu scrolls when it is taller than the window (fixes #1370)
2026-10-30 16:05:27 -04:00
Brandon Thomas dc1fc8d83b Merge pull request #1418
fix(slides): dragging several selected slides moves them all
2026-10-30 16:05:27 -04:00
Brandon ThomasandClaude Opus 5.5 c66fee15f2 Merge origin/main into #1417: the symbol-font space guard moves to shape.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:05:02 -04:00
Brandon ThomasandClaude Opus 5.5 b7bec00386 Merge origin/main into #1449: preset tiles move into effects_tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:03:42 -04:00
Brandon ThomasandClaude Opus 5.5 087c11bb42 Merge origin/main into #1447: Size & Properties moves to size_tab, From uses craft_ui::ComboBox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:01:56 -04:00
Brandon ThomasandClaude Opus 5.5 4d2c1a3bac Clipboard HTML: put no HTML on the clipboard when even plain text is over the cap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:00:36 -04:00
Brandon Thomas 28756fc755 Merge pull request #1462
Docs: refresh gaps and roadmap against merged PRs
2026-10-30 15:59:10 -04:00
Brandon Thomas c3d1df6d28 Merge pull request #1382
feat(ui): support Alt+1..9 shortcuts for Quick Access Toolbar items
2026-10-30 15:59:10 -04:00
Brandon Thomas 2275e56319 Merge pull request #1518
Automatic lists work without Replace text as you type
2026-10-30 15:59:09 -04:00
Brandon Thomas c8d1a7c7df Merge pull request #1532
Normalise CRLF in replace_text before computing the diff
2026-10-30 15:59:09 -04:00
Brandon Thomas 8713686274 Merge pull request #1489
Start a new paragraph when an HTML block tag opens in paste

# Conflicts:
#	crates/engine/src/cmd/richclip.rs
2026-10-30 15:59:08 -04:00
Brandon Thomas e6f9e912f7 Merge pull request #1482
Fix HTML paste style leaking past closing tags of a, td, mark
2026-10-30 15:58:55 -04:00
Brandon Thomas 6208d3eaeb Merge pull request #1545
Fix whole-deck benchmark statistics
2026-10-30 15:58:55 -04:00
Brandon Thomas 58abe9ddb7 Merge pull request #1513
Bound MCP stdio request frames
2026-10-30 15:58:54 -04:00
Brandon Thomas 34cfc5b20b Merge pull request #1500
Allow multiple automation roots per kind
2026-10-30 15:58:54 -04:00
Brandon Thomas e279461ca7 Merge pull request #1493
fix(cli): support --cmd-file for batch commands (#1098), carried from #1150
2026-10-30 15:58:53 -04:00
Brandon Thomas 63934efc69 Merge pull request #1451
CLI: finish file output after stdout closes
2026-10-30 15:58:53 -04:00
Brandon Thomas f61b7a34dd Merge pull request #1560
Picture Format: the Picture Styles row leaves room for Crop and Size
2026-10-30 15:58:52 -04:00
Brandon ThomasandClaude Opus 5.5 b8cfcf20a2 Accessibility contrast skips line breaks and blank runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:58:38 -04:00
Brandon ThomasandClaude Opus 5.5 1841ab75da Spelling: check all-caps candidates in capitals so KEEPCASE words are suggested
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 c1b0cf72df Merge origin/main into #1438: port the Add Animation scroll onto ribbon/animations.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:55:09 -04:00
Brandon ThomasandClaude Opus 5.5 ba29b61f8c Keep backslash zip entry names with forward slashes so later lookups split them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:54:46 -04:00
Brandon ThomasandClaude Opus 5.5 11b271b9ae Tests: the Picture Styles resize test asks that the row grow at 1920 px, not for an exact count (#1560)
The count depends on how wide the rest of the Picture Format ribbon is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:54:46 -04:00
Brandon ThomasandClaude Opus 5.5 352fec7725 Insert and paste count group members when picking the next automatic name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:53:18 -04:00
JonRC 359e46f042 ROADMAP: place the Picture Styles rows beside the newer entries
Main added a status line and revision rows at the same spots, so these go one line further down.
2026-10-30 16:51:25 -03:00
Brandon ThomasandClaude Opus 5.5 ecad13d1cc Merge origin/main into #1418: port the slide drag onto panels/thumbs.rs and sorter/ui_tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:48:56 -04:00
Brandon Thomas 9554cc7eb8 Merge pull request #1525
Count histogram values on a bin edge in that bin
2026-10-30 15:48:46 -04:00
Brandon Thomas 7554d647cd Merge pull request #1517
Skip labels for missing bar chart points
2026-10-30 15:48:46 -04:00
Brandon Thomas e713bccefd Merge pull request #1469
Charts: hidden axis labels and unfilled gridlines are read from the chart XML (refs #1366)
2026-10-30 15:48:45 -04:00
Brandon Thomas 2153f8ac4d Merge pull request #1467
Animations: fix Darken and Desaturate preset ids and 14 default durations
2026-10-30 15:48:45 -04:00
Brandon Thomas 42b0e1de8f Merge pull request #1465
pptx: read media volume, mute, loop, hide and full screen from the timing tree (refs #1368)

# Conflicts:
#	crates/pptx/src/read/timing.rs
2026-10-30 15:48:44 -04:00
Brandon Thomas 9749ec992f Merge pull request #1435
fix(pptx): read Auto-reverse from animation behaviour nodes
2026-10-30 15:48:36 -04:00
Brandon Thomas f08e5f8691 Merge pull request #1384
PPTX: a picture in a picture placeholder takes its layout's shape
2026-10-30 15:48:35 -04:00
Brandon Thomas 5b0871e5f3 Merge pull request #1473
Spell check: don't check words longer than 100 characters (fixes #1175)
2026-10-30 15:48:35 -04:00
Brandon Thomas 0f5d01a461 Merge pull request #1324
Long English dates follow each English locale's day order and punctuation
2026-10-30 15:48:34 -04:00
Brandon Thomas 234329a8d2 Merge pull request #1323
A paragraph with its own numbering start restarts the count there
2026-10-30 15:48:34 -04:00
Brandon Thomas 71c5da07eb Merge pull request #1568
Don't warn about macOS hidden system fonts such as .SF NS (#1504)
2026-10-30 15:48:33 -04:00
Brandon Thomas a3d14bc8b0 Merge pull request #1497
Fonts: cap the faces read from a font collection at 256
2026-10-30 15:48:33 -04:00
Brandon Thomas 2feb7dc2b8 Merge pull request #1452
Fonts: a variable font's named instances answer to their Windows family names
2026-10-30 15:48:32 -04:00
Brandon Thomas 5187e01856 Merge pull request #1442
Bullets in an unavailable Unicode symbol font keep their character (fixes #1347)
2026-10-30 15:48:32 -04:00
Brandon Thomas 6030fb4f8c Merge pull request #1471
File > New: keep the template tiles inside the right margin in a narrow window
2026-10-30 15:48:31 -04:00
Brandon Thomas b3c36e816a Merge pull request #1560
Picture Format: the Picture Styles row leaves room for Crop and Size
2026-10-30 15:48:31 -04:00
Brandon Thomas 95a55b0ea7 Merge pull request #1437
fix(format): Reflection None clears after another preset (#1356)

# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-30 15:48:20 -04:00
Brandon Thomas 5e85f73c5e Merge pull request #1397
Format: Fill offers Slide background fill
2026-10-30 15:48:04 -04:00
Brandon Thomas 5191af8ebb Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 15:43:28 -04:00
Brandon Thomas 8d278d71cb Merge pull request #1456
Slide show: Set Up Slide Show can keep the mouse cursor visible (#1445)

# Conflicts:
#	crates/ui-egui/src/app/commands.rs
#	crates/ui-egui/src/lib.rs
2026-10-30 15:42:42 -04:00
Brandon Thomas 9cb51393b3 Merge pull request #1533
Slide show: going back plays the left slide's transition in reverse (#1531)
2026-10-30 15:42:30 -04:00
Brandon Thomas 2d48221edb Merge pull request #1512
Bound EMF+ region paths to declared spans
2026-10-30 15:42:30 -04:00
Brandon Thomas b3ccea8577 Merge pull request #1498
Decoders: set up picture state inside the panic guard
2026-10-30 15:42:29 -04:00
Brandon Thomas e96ae0b6f5 Merge pull request #1466
PDF export: transparent content pixels take the backdrop colour (fixes #1455)
2026-10-30 15:42:29 -04:00
Brandon Thomas 6fa022d50f Merge pull request #1441
Animation state no longer moves layout and master shapes that share an id with a slide shape (fixes #1362)
2026-10-30 15:42:28 -04:00
Brandon Thomas 62b06a0a91 Merge pull request #1226
Fix the editor's media bar ignoring clicks
2026-10-30 15:42:28 -04:00
Brandon Thomas c389f62b33 Merge pull request #1374
Render: skip shapes far larger than any screen instead of aborting (#1165)

# Conflicts:
#	crates/render/src/renderer.rs
2026-10-30 15:42:21 -04:00
Brandon Thomas 6d02e90177 Merge pull request #1453
Reject '+' sign in slide range numbers
2026-10-30 15:42:14 -04:00
Brandon Thomas 52d81a9468 Merge pull request #1538
Reject invalid slide index in design.applyIdea and design.ideas
2026-10-30 15:42:13 -04:00
Brandon Thomas 81ab1bce0d Merge pull request #1537
Reject invalid slide index in animation.clear
2026-10-30 15:42:13 -04:00
Brandon Thomas 3465ed9d47 Merge pull request #1410
Reject invalid slide index in file.export, file.render, slide.colorAt

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-30 15:42:12 -04:00
Brandon Thomas f29b7e7eed Merge pull request #1481
Tests pinning picture.reset, a repeated PNG export and an action button's theme fill

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-30 15:42:11 -04:00
Brandon Thomas 8075b7ecd8 Merge pull request #1480
show.presenter answers {on, window} (part of #298)
2026-10-30 15:42:11 -04:00
Brandon Thomas a37fa65852 Merge pull request #1479
review.compare says which side each change is on; an absent box is not a rotation
2026-10-30 15:42:10 -04:00
Brandon Thomas 21bbb4127d Merge pull request #1478
A group's box follows its children after a command moves or resizes one

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-30 15:42:10 -04:00
Brandon Thomas 36eaa7e051 Merge pull request #1477
Inserts in the master views: insert.dateTime fills the master's date placeholder; answers say a master or layout was edited (part of #298)

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-30 15:41:59 -04:00
Brandon Thomas 932ec7bef6 Merge pull request #1475
table.cellAlign takes a horizontal align
2026-10-30 15:41:42 -04:00
Brandon Thomas 34af55bd6b Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 15:38:10 -04:00
Harrison Booth 90c8f65f5e Merge remote-tracking branch 'origin/main' into dc-slide-actions-list 2026-10-30 14:01:06 -05:00
Harrison Booth b082f12f70 #129: inspect slide actions 2026-10-30 14:01:04 -05:00
MarekSpodBiedry fe54c0eb3c Don't warn about macOS hidden system fonts such as .SF NS (#1504) 2026-10-30 14:59:05 +01:00
Dylan M. Taylor 3a4e30e10e UPD.1: honor pretend versions and restore dev-testkit checks (#1562) 2026-10-30 02:33:11 -04:00
Dylan M. Taylor 505a6f28ac Update prompt shows the app icon and accent color instead of a letter (#1561)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#f07a1e, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/engine/src/tools/click.rs
- crates/engine/src/tools/pointer.rs
- crates/metafile/src/emfplus.rs
- crates/model/src/trendline.rs
- crates/pptx/src/read/dml.rs
- xtask/src/layers.rs
2026-10-30 01:28:38 -04:00
JonRC 0da9cac9dc Picture Format: a resize alone brings the Picture Styles row to the new width
The ribbon now asks for one more frame when a tab's spare room changes by
more than half a point, so the row is sized for the new width even when no
input follows the resize. The test resizes 1440 to 1920 and back and checks
the harness settles.
2026-10-29 23:25:54 -03:00
Dylan M. Taylor 97db282daf CI: workflows only call craft-common-actions at @main (#1557)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:14:18 -04:00
JonRC 99bb0f0a62 Picture Format: the Picture Styles row leaves room for Crop and Size
The gallery put all 24 picture styles in the ribbon row, about 1370 px, so
Alt Text, Arrange, Crop and Size sat past the ribbon's edge in any window
narrower than about 2500 px, the default 1440 px window and a maximized
1920 px one included. The row now shows as many styles as leave room for
the groups after it (at least two), sized from the width the tab left free
on the last frame, and an arrow opens all 24 in a menu, as the Transitions
and Animations galleries do.
2026-10-29 23:12:08 -03:00
Zach BerryandClaude Sonnet 5.5 afe4c324a7 [II5] A paragraph with its own numbering start begins a new run at that number
Layout added the paragraph's start to the running count, so start k set
paragraph by paragraph read 1, 3, 5. A change of start_at between
neighbouring numbered paragraphs now restarts the count, as PowerPoint's
Start at does; the same start on every paragraph still counts on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:02:10 -05:00
Harrison Booth 079ea961dd Review accessibility contrast across text runs 2026-10-29 10:40:21 -05:00
Harrison Booth f7b76e7894 CLI: fix whole-deck benchmark statistics 2026-10-29 10:34:33 -05:00
NetterSilverandClaude Haiku 5.5 35a9b812fd Reject invalid slide index in design.applyIdea and design.ideas
Found while testing crates/engine/src/cmd/ideas.rs:254 (apply_cmd); same pattern in ideas_cmd :248

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:19:26 +01:00
Piyush Goel 8333c371e1 feat(ui): support Alt+1..9 shortcuts for Quick Access Toolbar items 2026-10-29 16:20:29 +05:30
NetterSilverandClaude Haiku 5.5 1bdd63dabe Reject invalid slide index in animation.clear
Found while testing crates/engine/src/cmd/animation.rs:342 (clear)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:27:56 +01:00
NetterSilverandClaude Haiku 5.5 6fbfea12a6 Suggest corrections for misspelt all-caps words
Found while testing crates/engine/src/spell.rs:396-448 (Dictionary::suggest)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:09:07 +00:00
FaabyyandClaude 62e020c1c1 Docs: animation parity lists the reversed transition on Back (#1531)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 09:34:43 +00:00
FaabyyandClaude 8cc25169ab Slide show: going back reverses the left slide's transition (#1531)
Back from slide B to slide A played A's own entrance transition. PowerPoint
plays the transition B came in with, backwards.

ShowState now remembers that the current slide was reached by Back
(`backward`, set by `prev`, cleared by every other way in). The show then
takes the transition of the slide it left and runs its frames from the end
to the start, with the old and new slides trading places
(`transition_layers`). Morph needs no reversing: a Morph from the slide
left to the one come back to already is the reversed Morph. A jump (Go to
Slide, links, See All Slides) still plays the target slide's transition.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 09:33:59 +00:00
NetterSilverandClaude Haiku 5.5 9e5afc2c90 Normalise CRLF in replace_text before computing the diff
Found while testing crates/model/src/edit.rs:420-439 (replace_text), reached from crates/engine/src/cmd/slide.rs:556 (notes)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:10:51 +01:00
NetterSilverandClaude Haiku 5.5 0e556d4e84 Count histogram values on a bin edge in that bin
Found while testing crates/model/src/chart.rs:1273 (Chart::histogram_bins, bin index computation)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:17:20 +01:00
Harrison Booth cfd30ce4b2 M1.2: deduplicate font collection offsets early 2026-10-28 17:49:33 -05:00
Harrison Booth 23c19d8e0c Merge remote-tracking branch 'origin/main' into dc-font-cap 2026-10-28 17:35:36 -05:00
Harrison Booth 14eef3aceb dc-show-click-targets: follow animated show click targets 2026-10-28 16:09:41 -05:00
Harrison Booth 33b6e11726 Use private directories for print previews 2026-10-28 15:48:37 -05:00
Harrison Booth 6d2fd18923 autocorrect: decouple lists from replacements 2026-10-28 15:40:36 -05:00
Harrison Booth d8c1880331 #1366.7: omit labels for missing bar values 2026-10-28 15:33:09 -05:00
Harrison Booth 8c2560f31c codec: validate configuration NAL types 2026-10-28 15:06:23 -05:00
Harrison Booth 2ba45cdd4c dc5-10: bound MCP stdio frames 2026-10-28 14:58:45 -05:00
Harrison Booth d9f90c517d fix: bound EMF+ region path reads (dc-emf-region-span) 2026-10-28 14:57:36 -05:00
Harrison Booth 2ac978ce54 dc5-02: enforce clipboard HTML size cap 2026-10-28 14:49:53 -05:00
Dylan M. Taylor f893f6af7d Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1507)
* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds

package.sh took the arch from uname -m and read binaries from target/release,
so a cross-built aarch64 target would be packaged as the host arch from the
wrong directory. Pass --target, read target/<triple>/release, and name the
archive from the target triple when CARGO_BUILD_TARGET is set. Skip the
--version smoke run for cross builds, since the binary cannot run on the host.

* Resolve host-tuple and reject unsupported targets in FreeBSD packaging

Resolve CARGO_BUILD_TARGET=host-tuple to the real host triple before it is used in
the target directory path. Accept only x86_64-unknown-freebsd and
aarch64-unknown-freebsd when it is set, and fail with an error for anything else
instead of mapping unknown targets to x86_64.

* Validate staged FreeBSD binaries before packaging

Check that deckcraft and deckcraft-cli are 64-bit little-endian FreeBSD ELF files
for the selected arch before the archive is created, so placeholder, wrong-arch
or non-FreeBSD binaries fail instead of being packaged. Applies to --skip-build
and full builds.
2026-10-28 15:35:40 -04:00
Harrison Booth 6d699b484f M1.2: bound collection face enumeration 2026-10-28 13:37:24 -05:00
Harrison Booth 22d9796786 Merge remote-tracking branch 'origin/main' into dc-font-cap 2026-10-28 13:34:37 -05:00
FaabyyandClaude 83ad5d287c Merge upstream/main into feat/show-keep-cursor-1445
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 18:22:37 +00:00
FaabyyandClaude 9388884d20 Docs: the keep-cursor preference is showKeepCursor in ui.json; roadmap header for #1445
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 18:17:11 +00:00
Harrison Booth e0c64599cc dc-upkeep-7: allow larger command-file media 2026-10-28 13:09:02 -05:00
Harrison Booth b54edd61b0 dc-upkeep-7: assert exact fallback bullet glyph 2026-10-28 12:41:58 -05:00
Harrison Booth d0f0d14c99 dc-upkeep-7: cap command-file line reads 2026-10-28 12:37:34 -05:00
Dylan M. Taylor c2f3397483 Grant network access so update checks can reach GitHub (#1502) 2026-10-28 13:07:13 -04:00
Harrison Booth d1487e6916 MCP: accept multiple automation roots 2026-10-28 11:16:28 -05:00
Harrison Booth 63b181170c F-19: guard decoder picture setup against panics 2026-10-28 09:56:13 -05:00
Harrison Booth 352086304e F-09: cap font collection face enumeration 2026-10-28 09:55:23 -05:00
Harrison Booth 6a9f18af92 Merge origin/main into the --cmd-file branch (#1150) 2026-10-28 09:44:21 -05:00
NetterSilverandClaude Haiku 5.5 fd102fb47b Start a new paragraph when an HTML block tag opens in paste
Found while testing crates/engine/src/cmd/richclip.rs:121-170 (html_to_body, opening-tag handling; is_block at :190)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:44:26 +01:00
dotCooCoo edbf542570 Format: Fill hides the kept group fill's editors with Slide background fill
A group that keeps a gradient, pattern or picture fill for its pictures
no longer shows that fill's editor while its shapes have the slide
background fill, so editing it cannot replace their fill.
2026-10-28 04:15:58 -07:00
dotCooCoo fca0492725 Format: Fill shows Slide background fill for a group that keeps its fill
A group whose picture uses the group's fill keeps that fill when its
shapes take the slide background fill. The pane now shows Slide
background fill chosen for that group.
2026-10-28 04:10:38 -07:00
dotCooCoo b57ab3fb35 Merge commit 'refs/check/mw-deckcraft-main' into feat/slide-background-fill
# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/ui-egui/src/panes.rs
2026-10-28 03:50:51 -07:00
NetterSilverandClaude Haiku 5.5 e75a72c0cc Fix HTML paste style leaking past closing tags of a, td, mark
Found while testing crates/engine/src/cmd/richclip.rs:121-139 (html_to_body), with tag_kind at :175

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 04:52:34 +01:00
Zach BerryandClaude Sonnet 5.5 cd5ff01810 [NN6] review.compare treats an absent box like an unrotated, unflipped one
slide.reset drops a placeholder's stored box, and compare called that
"rotated" because None differed from a stored rotation of 0. Absent now
equals 0 for rotation and no flips; a real flip difference reads "flipped".
PowerPoint's Compare reports a rotation only when the angle differs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 21:43:36 -05:00
Harrison Booth 9c24a63d1e Format Shape: the shadow detail controls read the inner shadow first, as the preset row does 2026-10-27 21:28:23 -05:00
Harrison Booth 5638b0ca09 Format Shape: the glow size test checks the colour and transparency are kept 2026-10-27 21:11:54 -05:00
Harrison Booth 8dfa6f1994 pptx: Auto-reverse is read only when every behaviour node has autoRev 2026-10-27 21:04:00 -05:00
Harrison Booth ddd7d210ef Duplicate: number the copy from the shape count, not the count of distinct names 2026-10-27 21:02:22 -05:00
Harrison Booth d8c88cea1d Format Shape: Scale and a centre origin need a single selected shape 2026-10-27 20:57:04 -05:00
Harrison Booth 322be04e7b Format Shape: a glow size tile keeps the glow's colour and transparency 2026-10-27 20:55:03 -05:00
Zach BerryandClaude Sonnet 5.5 2cad063d1d [MM4] A group's box follows its children after a command moves one
shape.move or shape.setBounds on a group's child left the group's box as it
was, so inspect_slide reported stale bounds. After any edit that changes a
child's box or the child list, the group's box is refitted to the union of its
children (its child space too, so every child keeps its place on the slide).
A group that was itself moved or resized, and SmartArt groups (regenerated
inside their own box), are left alone.

PowerPoint: a group's bounding box always hugs its children; dragging one
child out grows the group, dragging it back shrinks it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:38:41 -05:00
Zach BerryandClaude Sonnet 5.5 3356bc071c [SS1] Pin that an inserted action button's fill follows the theme
It already does: insert.actionButton gives the shape the accent1 style with no explicit fill, as
PowerPoint's gallery button has, so a theme change recolours it. No code change; a test pins it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:37:48 -05:00
Zach BerryandClaude Sonnet 5.5 0efd6f1b85 [HH12] Pin that exporting a slide again overwrites the PNG
Not a bug: file.export writes the second PNG over the first with the edit in
it. The test pins it (export, edit, export to the same path, files differ).

PowerPoint: Save As / Export to an existing file name replaces the file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:37:48 -05:00
Zach BerryandClaude Sonnet 5.5 1ca71474a2 [JJ3] show.presenter answers {on, window}
The answer was null. Presenter view is its own window beside the show window,
so window is true; the doc says where it appears.

PowerPoint: Presenter View opens in a separate window on the second display
when the show starts (the Use Presenter View option); same here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:34:18 -05:00
Zach BerryandClaude Sonnet 5.5 f58ec39968 [CC3] table.cellAlign takes a horizontal align
align: left|center|right|justify sets every paragraph of the target cells;
anchor stays the vertical alignment and is optional when align is given.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:31:46 -05:00
Ayoze Torres d027f9d866 Spell check docs: state the 100-character limit instead of linking a private constant (#1175) 2026-10-28 01:13:04 +00:00
Ayoze Torres 7c891de450 Spell check: don't check words longer than 100 characters (#1175)
Dictionary::check splits a word at every point looking for compound
parts, so a check costs O(n²) in the word's length under a compounding
dictionary (German, Dutch, …). A 100,000-character "word" took about
17 s per check in release, and spell check runs again on every edit.
suggest() was already capped at 64 characters; check() wasn't.

Words longer than MAX_CHECK_CHARS (100, Hunspell's word length limit)
now pass unchecked.
2026-10-28 01:01:00 +00:00
Harrison Booth b5928a182e Keep the File > New tiles inside the right margin in a narrow window (#1421)
set_max_width(CONTENT_W) widened the page past the margin whenever the window was
narrower than CONTENT_W, so the last tile in each row ran off the screen edge.
2026-10-27 19:50:52 -05:00
Harrison Booth 5896859440 Charts: tickLblPos none hides value-axis labels and unfilled gridlines are not drawn (refs #1366) 2026-10-27 19:31:45 -05:00
Harrison Booth d989389441 fix(format): a slider commits only a move to another shown value
changed() also fires when the slider rounds the value it shows, on focus too. Compare the rounded
values, so keyboard and accessibility edits commit without focus and rounding does not.
2026-10-27 19:31:36 -05:00
Harrison Booth 31efc67f73 test(text): the bullet is the ❑ or the dot the face draws, by glyph 2026-10-27 19:28:48 -05:00
Harrison Booth 8680166081 test(render): the shared shape id also hides a slide shape in the collision test 2026-10-27 19:18:53 -05:00
Harrison Booth 90ce7dcb9b test(format): clear the reflection after each preset, as #1356 reports 2026-10-27 19:18:14 -05:00
Harrison Booth 9d32bc4da9 fix(text): keep private-use bullets in named Unicode symbol fonts; .notdef falls back to a dot
Private-use codes are mapped by code only when no bullet font is named or it is a legacy symbol
font. A bullet that the cascaded face still cannot draw uses the dot instead of .notdef.
2026-10-27 19:17:11 -05:00
Harrison Booth faf4c0ee52 fix(format): Reflection slider with only focus does not re-add the reflection
A reflection slider that had focus and lost it to the None button committed its stale value.
Commit on focus loss only after an edit the user made, and cover it with a kittest.
2026-10-27 19:10:49 -05:00
Zach BerryandClaude Fable 5.1 4c83fb9abe [CCC2] Insert answers say when they edited a master or layout
PowerPoint shows the Slide Master tab while a master or layout is open,
so inserting there is never mistaken for editing a slide. DeckCraft's
insert commands answered the same in both views. Session::execute now
adds editing: {kind, name} (as slide.inspect does) to the answers of
insert.*, shape.insert, shape.freeform, shape.connect and slide.new in
master, layout, handout and notes-master view; answers on slides are
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 18:55:41 -05:00
Zach BerryandClaude Fable 5.1 6a1b8f1e39 [ZZ1] insert.dateTime in the Handout/Notes Master view fills the master's date placeholder
PowerPoint: Header and Footer > Notes and Handouts > Date and time puts a date field in the
date placeholder of the notes and handout masters, and every handout and notes page shows it.
Before, with no text caret the command wrote the current slide's date placeholder (design
.headerFooter) even in those views, so the handout PDF showed nothing. Now it writes the selected
date placeholder, else the master's own, as a field (update) or plain text, answers where:
master, and refuses (changing nothing) when the master has no date placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 18:55:41 -05:00
Harrison Booth 1e842137b0 Animations: Darken and Desaturate use PowerPoint's preset ids and HSL shift, and 14 effects get its default durations 2026-10-27 18:53:24 -05:00
Harrison Booth d6f532bb0d PDF export: transparent content pixels take the backdrop colour (fixes #1455)
The content raster's fully transparent pixels were RGB 0,0,0. A viewer that
smooths the image blends colour and mask separately, so the black showed as a
grey hairline around white pictures and white-filled shapes.
2026-10-27 18:52:43 -05:00
Harrison Booth 952e4db95f Merge remote-tracking branch 'origin/main' into dc-fix-reflection
# Conflicts:
#	crates/ui-egui/src/panes.rs
2026-10-27 18:47:18 -05:00
Harrison Booth f4ca20659c Merge remote-tracking branch 'origin/main' into dc-fix-ids-1362
# Conflicts:
#	crates/render/src/lib.rs
2026-10-27 18:43:17 -05:00
Harrison Booth b5257f9446 Merge remote-tracking branch 'origin/main' into dc-fix-bullet-1347
# Conflicts:
#	crates/text/src/lib.rs
2026-10-27 18:41:43 -05:00
Zach BerryandClaude Sonnet 5.5 50b200be7c [HH5] Long English dates follow each English locale's day order and punctuation
PowerPoint formats the datetime types by the run's language: en-GB's long
date is "18 October 2026" and the weekday form "Sunday, 18 October 2026".
We gave every English locale the US order ("October 18, 2026"), and a test
pinned that. datetime2 and datetime4 now put the day first for en-GB, en-AU,
en-NZ, en-IE and en-IN (the locales the numeric date already handled), and
datetime2 also for en-ZA. The punctuation follows PowerPoint on Windows as
checked per locale in review: en-IE writes no comma after the weekday
("Tuesday 27 October 2026"), and en-GB and en-AU put a comma after the month
in datetime3 ("27 October, 2026"). Other languages still have no month names,
so they are refused as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:24:46 -05:00
Zach BerryandClaude Sonnet 5.5 48535be970 [JJ1] review.compare says which side each change is on
Each change now carries number (the editor's slide number beside the 0-based
slide) and onlyIn (deck or file; absent for a change in both). Shape details
say (only in the deck) / (only in the file). The docs say review.accept takes
the file's side.

PowerPoint: Compare marks the other file's differences as revisions that
Accept applies and Reject discards, so accepting takes the file's side; kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:24:31 -05:00
Zach BerryandClaude Sonnet 5.5 7b424a463b [HH9] picture.reset: document and pin what it keeps (frame shape, size, position)
PowerPoint: Reset Picture discards the picture's formatting changes
(corrections, recolor, artistic effect, picture-style effects and outline);
Reset Picture & Size also restores the size. The frame shape set by Change
Shape is not touched. Ours already left the geometry alone (the reported
geometry wipe does not reproduce); effects, outline and crop are picture
formatting that PowerPoint discards too, so they stay reset (declined:
parity). Added the regression test and the command doc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:24:31 -05:00
Harrison Booth 964f4759c7 pptx: read volume, mute, loop, hide and full screen for media from the timing tree 2026-10-27 15:28:52 -05:00
Harrison Booth 110d46daae Docs: address review notes (command count 328, Linux paste qualifier, remaining #77 mentions) 2026-10-27 14:36:54 -05:00
Harrison Booth b003dde823 Docs: refresh gaps and roadmap against merged PRs
Remove the duplicate header lines in gaps.md, ROADMAP.md, ui-parity.md and
localization-parity.md. Drop gap entries that landed: Morph outline alignment
(#67) and animation rewind (#77). Record that PR #75 was closed unmerged so
#60 stays open, and that #53 is closed with Windows image paste tracked in
#1414. Note Reuse Slides (#1103) and Compare reject (#850). Command count 323.
2026-10-27 14:23:51 -05:00
NaseqandClaude Opus 5.5 feecee0a30 Slide show tests: check the pending preview and the one-render gate directly (#964)
A harness runs frames until no repaint is asked for, so the preview had
already landed when its first assertion ran. One raw frame now shows the
preview pending and one render started, and the paging test pins a pending
job and checks a page turn starts no other render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:13:44 +04:30
NaseqandClaude Opus 5.5 356195ba33 Slide show: render the Presenter View's next-slide preview off the UI thread (#964)
Turning the page with Presenter View on re-rendered the next-slide preview
synchronously, single-threaded, on the UI thread. The preview now renders
on a worker thread and arrives on a later frame; the web build (no
threads), or a worker that dies, renders it inline. At most one render runs:
paging faster than it asks for the newest preview once it is done, and a
finished one no longer wanted is dropped. Job and texture are keyed by the
presentation as well as the slide, so a preview rendered before an edit is
rendered again. While a preview is on its way the box stays empty rather
than saying "End of slide show".

Rebased onto the show module split: show/presenter.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:53:57 +04:30
NaseqandClaude Opus 5.5 b1645b006e Render: skip shapes whose outline is not finite or far larger than any screen (#1165)
An ellipse at the EMU limit, compounding group scales or custom-geometry
guides could reach vello's flattener with an outline billions of pixels
long; it sizes its buffer by that length and the allocation aborts the
process. geometry::drawable() rejects a shape with a non-finite path
point, then measures the outline's control box (which always holds the
curve, unlike the tight bounding box when its extremum overflows) unioned
with the frame; past 1e7 device pixels the shape is skipped with a warning.

Rebased onto the render module split: the guard now lives in geometry.rs
and renderer.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:12:07 +04:30
FaabyyandClaude b9842d0b82 Slide show: Set Up Slide Show can keep the mouse cursor visible (#1445)
A full-screen show hides a still pointer after 2.5 s, which makes decks
with clickable, branching choices hard to use. Set Up Slide Show now has
Keep mouse cursor visible (`show.keepCursor {on?}`): the show then keeps
the normal pointer, and the pointing hand over links and actions, while
links still follow. It is a user preference kept in ui.json, since PPTX
has no place for it; off by default, Reading View unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-27 10:50:26 +00:00
NetterSilverandClaude Haiku 5.5 cd2e780432 Reject '+' sign in slide range numbers
Found while testing crates/engine/src/cmd/file.rs:390-400 (parse_slide_range)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 09:19:49 +01:00
neoandClaude Opus 5.5 20553865cc Fonts: a variable font's named instances answer to their Windows family names
Documents name non-RIBBI weights by their Windows family ("Montserrat ExtraBold" / Regular).
The bundled Montserrat is variable, so its instances had no Windows names; the installed static
files that do carry them were skipped as already present, and the lookup fell back to Inter
Regular without reporting the font missing. Each named instance other than Regular, Italic,
Bold and Bold Italic now also answers to "<family> <weight>" with a Regular or Italic style,
as Windows lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:10:58 -07:00
Dominic Anaeto d8961ebc1e P0.2a: finish file output after stdout closes 2026-10-27 02:24:42 -04:00
Harrison Booth 9ad147b310 Format Shape Effects: drawn Shadow, Glow and Soft Edges preset tiles, and a shadow Size slider 2026-10-26 21:51:54 -05:00
Harrison Booth 6fa873dcfc feat(ui-egui): Scale Height/Width and a From origin in Format Shape's Size & Properties
Scale is measured from the size the tab first showed, like the Size and Position
dialog, and runs shape.sizePosition (honouring Lock aspect ratio). From switches
Horizontal/Vertical between the top-left corner and the centre. The lock checkbox
used a fresh id every frame so it never held; it now keeps its state.
2026-10-26 21:18:44 -05:00
Harrison Booth 65826295cc fix(text): draw Unicode symbol-font bullets with the fallback chain, not a dot (#1347)
A buFont named like Noto Sans Symbols was treated as a legacy symbol font, so its
bullet characters mapped to a generic dot. Only Wingdings and Symbol map by code now;
the bullet's own face is chosen by the usual cascade instead of a fixed Inter fallback.
2026-10-26 18:20:23 -05:00
Harrison Booth 8c8ab9561b fix(render): animation state never applies to layout and master shapes sharing a slide shape id (fixes #1362) 2026-10-26 18:20:19 -05:00
62ea604394 Animations: the Add Animation menu scrolls when it is taller than the window (fixes #1370)
Every effect in one column is taller than a 1440p screen, and the popup
did not scroll, so the last entries could not be reached. Cap the menu at
the window height minus the title bar and ribbon, and scroll inside it.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:48:25 +01:00
Harrison Booth fa0a3e9dee Duplicate: count the names inside groups when numbering copies 2026-10-26 17:40:21 -05:00
Harrison Booth 1b2e5ea9e0 fix(format): keep explicit slider edits, skip only the rounding 2026-10-26 17:36:59 -05:00
Harrison Booth c5994e16db fix(pptx): read Auto-reverse from animation behaviour nodes (#1363)
PowerPoint writes autoRev on p:cBhvr/p:cTn, not the effect node, so Grow/Shrink with auto-reverse grew and held. Color Pulse writes it there as part of the effect and is left out. Tests patch autoRev onto the behaviour node of a saved deck and read it back. Not checked against real PowerPoint: no PowerPoint COM here.
2026-10-26 17:34:28 -05:00
Harrison Booth 5a28760b1e fix(format): Reflection None clears after another preset (#1356)
The effect sliders rounded 55.000000000000007 to 55 and read that as a change,
so each frame re-added the reflection the None button had just cleared.
2026-10-26 17:29:31 -05:00
Harrison Booth 8ada601630 Duplicate gives the copy the next automatic name and keeps a chosen name, as PowerPoint does 2026-10-26 17:17:53 -05:00
Leonardo Basilio b31a59ad89 Merge branch 'main' into fix/picture-placeholder-geometry 2026-10-26 10:41:04 -03:00
fchinuandClaude Opus 5.5 039395ae4f fix(slides): dragging several selected slides moves them all
Dragging one of several selected slides in the thumbnail pane or the
Slide Sorter moved only the dragged slide. Now the whole selection moves
as one run in deck order, in one undo step, stays selected and joins the
section it lands in; an unselected slide still moves alone.

- slide.move takes `ids` (checked against the deck while parsed, repeats
  fold), alongside the unchanged `{from, to}`.
- The slide drag records its slides and the presentation it began in; a
  drag in another presentation, or one whose release no view saw (a
  Sorter double-click opening Normal view mid-gesture), is dropped
  instead of moving slides on a later release.
- Normal-view thumbnails carry a "Slide N" label, like Sorter cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:55:14 +01:00
kinower ae30368106 Merge main into fix/editor-media-bar-clicks 2026-10-26 13:35:37 +01:00
Dominik StrebelandClaude Opus 5.5 0865152e86 Text: a space after a symbol-font character keeps the run's font
PowerPoint stores an arrow typed in a Wingdings run as U+F0E0 with
<a:sym typeface="Wingdings"/> on the whole run, and draws only the
symbol code in Wingdings: the space after it is the run's own font. A
synthetic deck with "→ Only …" (U+F0E0, a space, words) in one run with
a:sym, and the same with a:sym on the arrow's run alone, exported as
PDF, draws both lines alike. DeckCraft kept the space in the arrow's
face, since spaces, digits and punctuation stay in the face of the text
before them, and Wingdings' space is about four times a text font's: the
words started 20 pt further right.

A neutral character no longer stays in a symbol font's face
(is_symbol_font); it takes the run's font like any other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:27:58 +01:00
kinower 1956b6b491 PR #1226: keep editor playback on the media bar 2026-10-26 11:18:32 +01:00
dotCooCoo 6396c480b2 Format: Slide background fill keeps a group fill that a picture uses
When a group's shapes take the slide background fill, the group's own
fill stays when a picture, connector, table or chart in it is filled
with the group fill (grpFill), so that member draws as before.
2026-10-26 02:38:34 -07:00
dotCooCoo aef364867d Format: Slide background fill applies to shapes and text boxes only
shape.fill {background: true} leaves the fill of pictures, connectors,
tables and charts unchanged. Only a shape or text box (p:sp) saves
useBgFill in a .pptx. With a group selected, its shapes and text boxes
take the fill and the group's own fill is cleared. When no selected
object can take the fill, the command returns an error.

The Format pane leaves Slide background fill out of a picture's Fill
options, and shows it chosen for a group whose shapes and text boxes
all have it.
2026-10-26 01:56:23 -07:00
NetterSilverandClaude Haiku 5.5 78c77ad450 Reject invalid slide index in file.export, file.render, slide.colorAt
Found while testing crates/engine/src/cmd/file.rs:581 (export); same pattern in render :539 and color_at :550

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 05:16:56 +00:00
NetterSilverandClaude Haiku 5.5 939bd12832 Find zip entries stored with backslash separators in pptx packages
Found while testing crates/pptx/src/opc.rs:77-82 (Package::open_within), lookup in Package::get via norm at :258

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 03:56:04 +00:00
dotCooCoo 71352733d4 Format: Fill offers Slide background fill
Format Shape's Fill section had no Slide background fill option, and
shape.fill had no parameter for it. A shape opened from a file with
useBgFill showed Automatic chosen.

shape.fill {background: true} now fills a shape with the slide
background, and Format Shape lists Slide background fill after Pattern
fill. It has no Color button or Transparency slider. Saving writes
useBgFill, and the slide background shows through the shape.
2026-10-25 15:42:46 -07:00
LeonardoandClaude Sonnet 5.5 1705712a41 PPTX: a picture in a picture placeholder takes its layout's shape
A layout's round picture placeholder keeps a slide's photo in a circle in
PowerPoint. The reader marked only p:sp placeholders as inheriting their
geometry, so a p:pic placeholder with just a frame in its spPr came out as
a rectangle (with the layout's border). Mark p:pic placeholders too; the
renderer already clips pictures to their geometry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 17:00:05 -03:00
kinowerandClaude Opus 5.5 811758f791 UI: a triple click plays a video once, like a double click
The third click of a triple click is a click too; it no longer pauses the
video the first click started. The test also follows the video when a double
click opens the Format pane and moves it on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:20:57 +02:00
kinowerandClaude Opus 5.5 c3981daeb6 UI: a click the Eyedropper takes does not play a video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:59:14 +02:00
kinowerandClaude Opus 5.5 300872fb7d UI: let the editor's media bar take its clicks; click a video to play it
A press on the play/pause bar under a selected audio or video reached the
editor canvas, which started a selection marquee there; the bar hid while the
marquee was dragging, so its buttons never got the click and media only played
in the slide show. The bar's buttons and background now sense drags too, as
the GIF play button does, so the press stays on the bar.

A click on a video in the editor now also plays or pauses it (it selects it as
before; dragging still only moves it, and a modifier key leaves the click to
the selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:08:47 +02:00
Zaxaerith 6655a1d68d fix(cli): accept command files in run (#1098) 2026-10-22 16:42:30 +08:00
164 changed files with 6846 additions and 850 deletions
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
+2 -2
View File
@@ -24,8 +24,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# 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/windows-arm64.yml@main
with:
app-name: deckcraft
app-dir: DeckCraft
+8 -12
View File
@@ -6,11 +6,6 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
(`../<app>`).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
session, follow its standards, and contribute reusable learnings back there. Never code: repos
don't share code.
These rules bind every AI agent and every human contributor. They come before any task,
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
file wins.**
@@ -69,7 +64,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
or `docs/brand/` has no row.
- **`NOTICE`** keeps the notices the licences require.
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
`../craft-fonts`).
@@ -99,7 +94,6 @@ once and tell the user.
## 3. Never crash
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
Standard: craftrules `standards/never-crash.md`.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
@@ -160,7 +154,7 @@ to `main`.
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
docs follow craftrules `standards/progress-docs.md`:
docs:
| File | Holds |
|---|---|
@@ -184,11 +178,13 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
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`)
(copy the template at the top of the file)
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).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+1 -1
View File
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
| `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 |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (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") |
+6 -2
View File
@@ -9,8 +9,7 @@ before modules or other crate items; crate-level `//!` documentation may precede
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
## Habits
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
@@ -19,3 +18,8 @@ before modules or other crate items; crate-level `//!` documentation may precede
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- 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`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+45 -44
View File
@@ -222,7 +222,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -823,12 +823,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -851,7 +851,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"thiserror 2.0.21",
]
@@ -859,7 +859,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -868,7 +868,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -888,13 +888,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -983,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"arboard",
"chrono",
@@ -1005,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1014,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1023,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1040,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1073,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1085,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1096,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"kurbo",
"serde",
@@ -1104,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1113,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1122,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1146,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1164,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"image",
"kurbo",
@@ -1173,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1185,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.12.0"
version = "0.14.1"
[[package]]
name = "deckcraft-opus"
version = "0.12.0"
version = "0.14.1"
[[package]]
name = "deckcraft-pdf"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1211,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1227,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1246,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1260,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1285,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1294,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -1651,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2707,7 +2708,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3013,7 +3014,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3486,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.45.0",
]
[[package]]
@@ -4041,7 +4042,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4098,7 +4099,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4731,10 +4732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5658,7 +5659,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6176,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.14.1"
dependencies = [
"serde_json",
]
+7 -6
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.12.0"
version = "0.14.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,10 +59,11 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -108,7 +109,7 @@ codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+9 -1
View File
@@ -286,7 +286,15 @@ stand on its own.
| <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) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <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 decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+39 -5
View File
@@ -2,9 +2,20 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -43,7 +54,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md](
`.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),
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74),
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
[docs/roadmap.md](docs/roadmap.md).
@@ -116,11 +127,11 @@ ships 26.
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.
image paste on Windows (#1414), editor soak tests. 10–20 h.
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
and animation bounce end. Still open: a corpus of real decks, #70, #74, EMF+ records,
MicroType Express font subsets 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.
@@ -137,7 +148,18 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) |
| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide |
| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) |
| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear |
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
@@ -148,6 +170,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" |
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
@@ -161,6 +184,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it |
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
@@ -283,7 +307,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
| 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-10 | Progress docs restructured; 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 |
@@ -294,7 +318,16 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged |
| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged |
| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged |
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
@@ -312,6 +345,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 2026-10-26 | trivial | Progress log: spaces after symbol-font characters |
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
@@ -384,5 +418,5 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-10 | major | Restructured: 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 |
+17 -24
View File
@@ -4,29 +4,24 @@
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
/// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and
/// `--flag=DIR`) may be repeated.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -40,8 +35,7 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
roots.push(value.into());
Ok(true)
}
}
@@ -51,17 +45,16 @@ mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
fn roots_are_repeated_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--sample", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
@@ -71,8 +64,8 @@ mod tests {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
@@ -84,7 +77,7 @@ mod tests {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+18 -6
View File
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
},
Subcommand {
name: "run",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
summary: "run commands on a presentation, then save or export it",
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
line endings and an optional UTF-8 BOM at the start of the file are accepted.
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
File errors name the path; line-reading, JSON and command errors also name the line number.
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
deckcraft-cli run \\
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
@@ -127,16 +136,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe
},
Subcommand {
name: "mcp",
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...",
summary: "serve the commands to an agent over MCP on stdio",
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
--sample opens the sample presentation.
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
app is not confined by them).",
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either
flag to grant access to more folders; a root left out grants none of its access. They run the in-process
server, so they conflict with --connect (a running app is not confined by them).",
},
Subcommand {
name: "bench",
@@ -180,7 +189,10 @@ A presentation is edited by running commands on it:
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
`deckcraft-cli help run` explains how to write a command.",
`deckcraft-cli help run` explains how to write a command.
If an output reader closes early, run, render, render-transition and convert continue; their exit
status reflects the work, not output delivery. Stop the process to cancel them.",
list.join("\n")
)
}
+119 -22
View File
@@ -7,23 +7,38 @@ mod automation_args;
mod help;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use deckcraft_engine::Session;
use serde_json::{Value, json};
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
STDOUT_CLOSED.store(true, Ordering::Relaxed);
} else {
eprintln!("deckcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
eprintln!("deckcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
}};
}
fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
use std::io::Write as _;
#[cfg(unix)]
let mut stdout = {
use std::os::fd::AsFd as _;
std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)
};
#[cfg(not(unix))]
let mut stdout = std::io::stdout();
stdout.write_all(format!("{args}\n").as_bytes())
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest = args.get(1..).unwrap_or(&[]);
@@ -146,9 +161,13 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6
f()?;
ms.push(t.elapsed().as_secs_f64() * 1000.0);
}
Ok(summarize_ms(ms))
}
/// Summarize one population of measurements; each maximum must come from that population.
fn summarize_ms(mut ms: Vec<f64>) -> (f64, f64) {
ms.sort_by(f64::total_cmp);
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
Ok((median, ms.last().copied().unwrap_or(0.0)))
(ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0))
}
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
@@ -210,9 +229,13 @@ fn bench(args: &[String]) -> Result<(), String> {
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
];
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
let render_all = time_ms(runs, || {
for i in 0..slides {
deckcraft_engine::render::render_slide(&doc, i, &opts);
}
Ok(())
})?;
steps.insert(1, ("render all slides (1×)", render_all));
let peak = peak_rss_mib();
if as_json {
let v = json!({
@@ -450,6 +473,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
})
}
enum CommandSource {
Inline(String),
File(String),
}
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let count = newline.unwrap_or(available.len());
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
})?;
line.extend_from_slice(&available[..count]);
reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
return Ok(true);
}
}
}
/// The same command syntax and execution path for --cmd and each command-file line.
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
let (id, params) = match command.split_once('=') {
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (command, json!({})),
};
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
}
Ok(())
}
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
use std::io::BufReader;
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let mut reader = BufReader::new(file);
let mut bytes = Vec::new();
let mut line_number = 0usize;
loop {
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
break;
}
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
let command = line.trim();
if command.is_empty() || command.starts_with('#') {
continue;
}
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
@@ -463,7 +549,8 @@ fn run(args: &[String]) -> Result<(), String> {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--export-params" => {
@@ -486,14 +573,10 @@ fn run(args: &[String]) -> Result<(), String> {
}
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
let (id, p) = match c.split_once('=') {
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (c.clone(), json!({})),
};
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
for source in cmds {
match source {
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
}
}
if let Some(p) = save {
@@ -624,7 +707,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
other => return Err(format!("unknown mcp option `{other}`")),
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
@@ -641,8 +724,12 @@ so drop --connect (or use --connect without the roots)"
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
let show = |roots: Vec<String>| if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
eprintln!(
"deckcraft-cli: files confined: read roots {}, write roots {}",
show(roots.read_roots().map(|r| r.display().to_string()).collect()),
show(roots.write_roots().map(|r| r.display().to_string()).collect())
);
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
@@ -663,6 +750,16 @@ mod tests {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn timing_summary_keeps_median_and_max_from_one_population() {
let slide_medians = [8.99, 16.565];
let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]);
assert_eq!(whole_deck, (17.0, 18.0));
assert!(whole_deck.1 >= whole_deck.0);
assert!(slide_medians.iter().sum::<f64>() > whole_deck.1, "fixture must expose summing independent slide medians");
assert_eq!(summarize_ms(vec![]), (0.0, 0.0));
}
#[test]
fn render_help_prints_usage_and_writes_nothing() {
assert!(render(&args(&["--help"])).is_ok());
+150
View File
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn command_file_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn inspected_titles(out: &str) -> Vec<String> {
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
}
#[test]
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
let dir = command_file_dir("lines");
let file = dir.join("commands with spaces.txt");
std::fs::write(
&file,
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
)
.unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
let out = stdout(&o);
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 4);
assert_eq!(titles[1], "Résumé = # 测试");
assert_eq!(titles[3], "Last");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_files_and_inline_commands_keep_argument_order() {
let dir = command_file_dir("mixed");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
let saved = dir.join("result.deckcraft");
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
let o = cli(&[
"run",
"--cmd",
"slide.new={\"title\":\"Inline first\"}",
"--cmd-file",
&first.to_string_lossy(),
"--cmd",
"slide.new={\"title\":\"Inline middle\"}",
"--cmd-file",
&second.to_string_lossy(),
"--cmd",
"document.inspect",
"--print",
"--save",
&saved.to_string_lossy(),
]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
assert_eq!(out.lines().count(), 5);
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
let dir = command_file_dir("failure");
let file = dir.join("commands.txt");
let later = dir.join("later-in-file.deckcraft");
let inline = dir.join("later-inline.deckcraft");
let save = dir.join("saved.deckcraft");
let export = dir.join("exported.png");
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
std::fs::write(&save, b"keep saved file").unwrap();
std::fs::write(&export, b"keep exported file").unwrap();
let o = cli(&[
"run",
"--print",
"--cmd-file",
&file.to_string_lossy(),
"--cmd",
&inline_command,
"--save",
&save.to_string_lossy(),
"--export",
&export.to_string_lossy(),
]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_read_errors_name_the_path_and_utf8_line() {
let dir = command_file_dir("read-errors");
let missing = dir.join("missing.txt");
let invalid = dir.join("invalid.txt");
let save = dir.join("out.deckcraft");
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
for file in [&missing, &dir, &invalid] {
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&file.display().to_string()), "{err}");
if file == &invalid {
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
}
assert!(!save.exists());
}
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_rejects_an_oversized_line_before_running_it() {
let dir = command_file_dir("line-limit");
let file = dir.join("commands.txt");
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
assert!(o.stdout.is_empty(), "the oversized command must not run");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn inline_commands_keep_their_existing_syntax_and_errors() {
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 3);
assert_eq!(titles[2], "Inline = #");
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
}
#[test]
fn an_unknown_subcommand_is_named_before_the_overview() {
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
"Shape ids are looked up on the current slide only.",
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
"format.* acts on the selected text or shapes",
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
"commands execute in the order those options appear.",
"If a command fails, run stops there and saves nothing.",
] {
assert!(out.contains(fact), "{fact}: {out}");
}
@@ -0,0 +1,110 @@
use std::fs::{self, File};
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn closed_stdout() -> Stdio {
let (writer, reader) = UnixStream::pair().unwrap();
drop(reader);
Stdio::from(OwnedFd::from(writer))
}
fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()
}
#[test]
fn broken_pipe_does_not_skip_later_commands_or_save() {
let dir = Scratch::new("save");
let out = run(
&dir.0,
&[
"run",
"--cmd",
r#"slide.new={"layout":"titleOnly","title":"First"}"#,
"--cmd",
r#"slide.new={"layout":"titleOnly","title":"Later"}"#,
"--print",
"--save",
"out.deckcraft",
],
closed_stdout(),
);
assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let saved = dir.0.join("out.deckcraft");
assert!(saved.is_file(), "requested save was skipped");
let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
let slides = data["slides"].as_array().unwrap();
assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");
}
#[test]
fn broken_pipe_does_not_hide_a_later_command_failure() {
let dir = Scratch::new("command-failure");
let out = run(
&dir.0,
&["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],
closed_stdout(),
);
assert!(!out.status.success(), "a later command failure returned success");
assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");
assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");
}
#[test]
fn broken_pipe_does_not_hide_a_save_failure() {
let dir = Scratch::new("save-failure");
fs::create_dir(dir.0.join("out.deckcraft")).unwrap();
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());
assert!(!out.status.success(), "a failed save returned success");
assert!(!out.stderr.is_empty(), "missing save failure diagnostic");
}
#[test]
fn broken_pipe_does_not_skip_later_rendered_slides() {
let dir = Scratch::new("render-all");
let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());
assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));
let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());
assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));
let names = |folder: &str| {
let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();
files.sort();
files
};
let expected = names("normal");
assert!(expected.len() > 1, "sample must have multiple slides");
assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");
}
#[test]
fn other_stdout_write_errors_fail() {
let dir = Scratch::new("write-failure");
let path = dir.0.join("readonly");
fs::write(&path, b"").unwrap();
let readonly = File::open(path).unwrap();
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));
assert!(!out.status.success(), "a non-broken stdout error returned success");
assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));
}
+2
View File
@@ -1,4 +1,6 @@
mod cli;
#[cfg(unix)]
mod closed_stdout;
mod convert;
mod damaged;
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
+45 -2
View File
@@ -87,6 +87,45 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_accepts_multiple_read_and_write_roots() {
let base = tmpdir("multiple-roots");
let read1 = base.join("read1");
let read2 = base.join("read2");
let write1 = base.join("write1");
let write2 = base.join("write2");
for dir in [&read1, &read2, &write1, &write2] {
std::fs::create_dir_all(dir).unwrap();
}
let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt"));
std::fs::write(&input1, "one").unwrap();
std::fs::write(&input2, "two").unwrap();
let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft"));
let args = [
"--automation-read-root".to_owned(),
read1.to_string_lossy().into_owned(),
format!("--automation-read-root={}", read2.display()),
"--automation-write-root".to_owned(),
write1.to_string_lossy().into_owned(),
format!("--automation-write-root={}", write2.display()),
];
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let calls = [
("open_presentation", serde_json::json!({"path": input1})),
("open_presentation", serde_json::json!({"path": input2})),
("save_presentation", serde_json::json!({"path": output1})),
("save_presentation", serde_json::json!({"path": output2})),
];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
for reply in &replies {
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
assert!(output1.exists());
assert!(output2.exists());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
@@ -103,8 +142,12 @@ fn mcp_automation_root_flags_are_checked_up_front() {
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
for flag in ["--automation-read-root", "--automation-write-root"] {
let mut full = vec!["mcp", flag, dir, flag];
full.push(dir);
let out = run(&full);
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
}
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--sample"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
+3 -1
View File
@@ -60,6 +60,8 @@ impl eframe::App for App {
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
#[cfg(target_os = "macos")]
self.0.cleanup_print_previews();
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -187,7 +189,7 @@ 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();
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("DeckCraft update helper failed: {e}");
std::process::exit(1);
+10 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# 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):
# 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"
@@ -68,3 +65,11 @@ sync_github_name = true
[people.SofiaAFigueredo]
real_name = "Sofia Alves Figueredo"
[people.dylanmtaylor]
sync_github_name = true
[people.marshiyar]
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
+6
View File
@@ -58,6 +58,9 @@ pub struct ShowState {
pub playing: bool,
/// On the end screen.
pub ended: bool,
/// The current slide was reached by going back (← / Backspace), so the show plays the
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
pub backward: bool,
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
active: bool,
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
@@ -208,6 +211,7 @@ impl ShowState {
self.active = true;
self.slide = i;
self.ended = false;
self.backward = false;
// Time spent elsewhere never counts for this slide's triggered animations.
self.last_now = None;
let steps = ShowState::steps(pres, i);
@@ -309,6 +313,7 @@ impl ShowState {
}
self.step = ShowState::steps(pres, self.slide);
self.playing = false;
self.backward = false;
return ShowAction::Slide(self.slide);
}
// #844: undoing the automatic first step left the slide as before it appeared, and the show
@@ -337,6 +342,7 @@ impl ShowState {
self.step = ShowState::steps(pres, i);
self.playing = false;
}
self.backward = true;
ShowAction::Slide(i)
}
None => ShowAction::None,
+20
View File
@@ -763,6 +763,26 @@ fn show_skips_hidden_slides() {
assert_eq!(s.goto(&p, 99), ShowAction::None);
}
#[test]
fn show_remembers_a_slide_was_reached_by_going_back() {
// #1531: the UI plays the left slide's transition reversed only for Back.
let p = pres(3);
let mut s = ShowState::new(&p);
assert_eq!(s.next(&p), ShowAction::Slide(1));
assert!(!s.backward);
assert_eq!(s.prev(&p), ShowAction::Slide(0));
assert!(s.backward);
assert_eq!(s.next(&p), ShowAction::Slide(1));
assert!(!s.backward);
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
assert!(!s.backward, "a jump is not Back");
// Back from the end screen shows the last slide again, without a transition to reverse.
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
assert_eq!(s.next(&p), ShowAction::End);
assert_eq!(s.prev(&p), ShowAction::Slide(2));
assert!(!s.backward);
}
#[test]
fn show_loops() {
let mut p = pres(3);
+12 -2
View File
@@ -515,8 +515,18 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
}
}
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
/// Every name on the slide, group members included, and the number of shapes (which exceeds the
/// number of names when two shapes share one).
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet<String>, usize) {
let mut names = std::collections::HashSet::new();
let mut count = 0;
if let Some(v) = current_shapes(doc, sel) {
deckcraft_model::walk(v, &mut |sh, _| {
names.insert(sh.name.clone());
count += 1;
});
}
(names, count)
}
/// The command palette runs a command with `{}`. True when the params doc names a value that
+205 -10
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animation Styles",
["Animations", "Animation"],
None,
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
has_selection,
set
),
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Animation",
["Animations", "Advanced Animation"],
None,
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
has_selection,
add
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
["Animations", "Animation"],
None,
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
has_slide,
options
),
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
}
fn build(p: &Value) -> Result<Animation> {
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
let class = class_of(str_param(p, "class"));
let info = match class {
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
a.start = start_of(st);
}
if a.class == AnimClass::Path {
a.path = Some(default_path(&a.effect, &a.option));
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
} else if p.get("path").is_some() {
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
}
if a.effect == "spin" {
a.amount = Some(360.0);
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
Ok(a)
}
/// Most points of a motion path given as an array, and longest path string given as text.
const MAX_PATH_POINTS: usize = 1_000;
const MAX_PATH_TEXT: usize = 16 * 1024;
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
const MAX_PATH_OFFSET: f64 = 1.0e6;
/// A number of a path string: slide fractions, rounded to 6 places.
fn path_num(v: f64) -> String {
let t = format!("{:.6}", v);
let t = t.trim_end_matches('0').trim_end_matches('.');
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
}
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
fn raw_path_draws(text: &str) -> bool {
let cs: Vec<char> = text.chars().collect();
let (mut i, mut nums) = (0, 0usize);
while let Some(&c) = cs.get(i) {
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
let start = i;
i += 1;
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
i += 1;
}
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
i += 2;
while cs.get(i).is_some_and(char::is_ascii_digit) {
i += 1;
}
}
}
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
match tok.parse::<f64>() {
Ok(v) if v.is_finite() => nums += 1,
_ => return false,
}
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
i += 1;
} else {
return false;
}
}
nums >= 4
}
/// The motion path `path` asks for, as the OOXML path string animations store: either points
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
/// to return to the first) or that string itself.
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
let Some(v) = p.get("path") else { return Ok(None) };
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
match v {
Value::String(text) => {
let ok = text.len() <= MAX_PATH_TEXT
&& text.contains(|c: char| c.is_ascii_digit())
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
&& raw_path_draws(text);
if !ok {
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
}
Ok(Some(text.clone()))
}
Value::Array(points) => {
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
}
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
return Err(refuse("can't be placed: the slide has no size"));
}
let mut out = String::new();
for (k, pt) in points.iter().enumerate() {
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
};
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
}
if p.get("closed").and_then(Value::as_bool) == Some(true) {
out.push_str("Z ");
}
out.push('E');
Ok(Some(out))
}
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
}
}
/// Default motion paths, in slide-fraction units relative to the shape's position.
pub fn default_path(effect: &str, option: &str) -> String {
let (dx, dy) = match option {
@@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let a = build(p)?;
let a = build(p, slide_extent(s)?)?;
s.edit(|doc, sel| {
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
let sl = Arc::make_mut(sl);
@@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The slide's width and height in points.
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
let size = &s.doc()?.doc.slide_size;
Ok((size.width, size.height))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let a = build(p)?;
let a = build(p, slide_extent(s)?)?;
let path = a.path.clone();
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
for (k, id) in ids.iter().enumerate() {
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
sl.animations.push(x);
}
Ok(json!({"count": sl.animations.len()}))
Ok(match &path {
Some(path) => json!({"count": sl.animations.len(), "path": path}),
None => json!({"count": sl.animations.len()}),
})
})
}
@@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
"byLetter" => TextBuild::ByLetter,
_ => TextBuild::AsOne,
});
let path = path_param(p, slide_extent(s)?)?;
each_target(s, p, |a| {
if let (Some(path), AnimClass::Path) = (&path, a.class) {
a.path = Some(path.clone());
}
if let Some(o) = &option {
// Each target takes the option in its own effect's terms; one it has no such
// option for keeps its own.
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
if let Some(o) = resolve_option(allowed, o) {
if a.class == AnimClass::Path {
if a.class == AnimClass::Path && path.is_none() {
a.path = Some(default_path(&a.effect, &o));
}
a.option = o;
@@ -339,7 +446,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide);
let i = match opt_usize_param(p, "index", "animation.clear")? {
Some(i) => i,
None => s.doc()?.selection.slide,
};
s.edit(|doc, _| {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).animations.clear();
@@ -434,6 +544,19 @@ mod tests {
use super::*;
#[test]
fn clear_rejects_invalid_index() {
let mut s = Session::with_new();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
let id = r.get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
for bad_index in [json!(-1), json!(1.5)] {
assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err());
let left = s.execute("animation.get", &json!({})).unwrap();
assert_eq!(left.as_array().unwrap().len(), 1);
}
}
#[test]
fn animation_painter_copies_effects_between_shapes() {
let mut s = Session::with_new();
@@ -590,4 +713,76 @@ mod tests {
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
}
}
fn path_of(s: &mut Session) -> Option<String> {
let all = s.execute("animation.get", &json!({})).unwrap();
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
}
fn shape(s: &mut Session) -> u64 {
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
}
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
#[test]
fn a_custom_path_takes_its_points() {
let mut s = Session::with_new();
let id = shape(&mut s);
let w = s.doc().unwrap().doc.slide_size.width;
let h = s.doc().unwrap().doc.slide_size.height;
let r = s
.execute(
"animation.add",
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
)
.unwrap();
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
// closed shapes end back at the start; set replaces; no path keeps the default
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
.unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
}
#[test]
fn a_raw_path_string_is_kept_and_options_edits_it() {
let mut s = Session::with_new();
let id = shape(&mut s);
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
}
#[test]
fn a_bad_path_is_refused_and_changes_nothing() {
let mut s = Session::with_new();
let id = shape(&mut s);
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
for bad_path in [
json!([[0, 0]]),
json!([]),
json!([[0, 0], [1]]),
json!([[0, 0], ["a", 1]]),
json!([[0, 0], [1e300, 1]]),
json!([[0, 0], [null, 1]]),
json!("M 0 0 L <script> E"),
json!("not a path"),
json!("eeee1"),
json!("M 0 0 L 1e999 0 E"),
json!(5),
Value::Array(many),
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
] {
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
assert!(r.is_err(), "{bad_path:.60}");
}
assert!(
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
"path only fits a motion path"
);
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
}
}
+92
View File
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// After an edit: refit every group whose children's geometry changed to the union of its
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
/// shrinks the group). The children keep their place on the slide: the group's box and its child
/// space change together. `old` is the presentation before the edit; only slides the edit
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
/// that edit.
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
// insertion or deletion shifts the indices, and shape ids repeat across slides.
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
for slide in new.slides.iter_mut() {
let Some(before) = old_slides.get(&slide.id) else { continue };
if std::sync::Arc::ptr_eq(before, slide) {
continue;
}
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
refit_list(Some(&before.shapes), shapes, 0);
}
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
for master in new.masters.iter_mut() {
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
&& !std::sync::Arc::ptr_eq(before, master)
{
refit_master(before, std::sync::Arc::make_mut(master));
}
}
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
if let (Some(before), Some(after)) = (before, after)
&& !std::sync::Arc::ptr_eq(before, after)
{
refit_master(before, std::sync::Arc::make_mut(after));
}
}
}
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
refit_list(Some(&old.shapes), &mut new.shapes, 0);
for layout in new.layouts.iter_mut() {
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
}
}
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
// is bounded like the model's other walks.
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
return;
}
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
for sh in list.iter_mut() {
let before = by_id.get(&sh.id).copied();
let old_children = before.and_then(|b| match &b.kind {
ShapeKind::Group { children, .. } => Some(children),
_ => None,
});
let own_before = before.map(|b| {
(
b.xfrm,
match &b.kind {
ShapeKind::Group { child, .. } => Some(*child),
_ => None,
},
)
});
let smart = sh.diagram.is_some();
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
refit_list(old_children, children, depth + 1);
if smart {
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
continue;
}
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
continue;
}
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
if same {
continue;
}
let Some(group) = sh.xfrm else { continue };
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
let Some(u) = union_bounds(&xs) else { continue };
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
continue;
}
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
let placed = child_to_parent(&group, child, &union);
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
*child = union;
}
}
/// A child's xfrm in slide space, given its group.
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
+30 -6
View File
@@ -321,7 +321,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut shapes = clip.shapes.clone();
reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let mut taken = super::current_names(doc, sel);
let (mut taken, mut count) = super::current_names(doc, sel);
for sh in &mut shapes {
// Only a name already on the slide is changed (two shapes with one name are what the
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
@@ -330,9 +330,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
&& !n.is_empty()
&& n.bytes().all(|b| b.is_ascii_digit())
{
sh.name = super::insert::first_free_name(base, &taken);
sh.name = super::insert::first_free_name(base, &taken, count);
}
taken.insert(sh.name.clone());
deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| {
taken.insert(m.name.clone());
count += 1;
});
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
x.y += off;
@@ -508,6 +511,19 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
paste_plain(s, &t)
}
/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as
/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing
/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}"
/// counts as automatic.
fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet<String>, count: usize) {
let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) };
let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' '));
if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
sh.name = super::insert::first_free_name(&base, taken, count);
taken.insert(sh.name.clone());
}
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let off = s.prefs.duplicate_offset;
@@ -532,6 +548,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
})
.unwrap_or((off, off));
reid("edit.duplicate", doc, &mut copies)?;
// Every name on the slide, group members included, so a copy never repeats one.
let mut taken = std::collections::HashSet::new();
let mut count = 0;
deckcraft_model::walk(&list, &mut |sh, _| {
taken.insert(sh.name.clone());
count += 1;
});
walk_mut(&mut copies, &mut |c| {
rename_automatic(c, &mut taken, count);
count += 1;
});
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += dx;
@@ -540,9 +567,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
if sel.target == crate::Target::Slides {
c.ph = None;
}
if !c.name.is_empty() {
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
}
}
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
+16 -5
View File
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, opt_usize_param, str_param, usize_param};
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -395,6 +395,10 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
Some((a, b)) => (a.trim(), b.trim()),
None => (part, part),
};
let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
if !digits(a) || !digits(b) {
return Err(invalid());
}
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
@@ -536,7 +540,7 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec
fn render(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
let i = opt_usize_param(p, "slide", "file.render")?.unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("file.render", format!("no slide {i}")));
}
@@ -547,7 +551,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
let i = opt_usize_param(p, "slide", "slide.colorAt")?.unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide.colorAt", format!("no slide {i}")));
}
@@ -578,7 +582,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec<usize> = match usize_param(p, "slide") {
let slides: Vec<usize> = match opt_usize_param(p, "slide", "file.export")? {
Some(i) => vec![i],
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
None => vec![st.selection.slide],
@@ -763,7 +767,14 @@ mod color_at_tests;
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::write_file;
use super::{parse_slide_range, write_file};
#[test]
fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err());
assert!(parse_slide_range("+1-+3", 3).is_err());
assert_eq!(parse_slide_range("1-3", 3).unwrap(), vec![0, 1, 2]);
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
+2 -2
View File
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::text::{format_body, format_para, format_run, format_run_levels};
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
has_text_or_shapes, ids_param, ok, str_param,
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Numbering",
["Home", "Paragraph"],
None,
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n (a numbered paragraph whose start differs from that of the last numbered paragraph at its level begins a new run of numbers at n, as PowerPoint's Start at does), color?, size?: % of text}",
has_text_or_shapes,
numbering
),
+16 -5
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
};
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
@@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st;
let base = ctx_sizes;
format_run(s, &move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
})
// A layout/master level steps from its own effective size, not the first run's.
format_run_levels(
s,
&move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
},
&move |r, eff| {
let cur = r.size.or(eff.size).unwrap_or(18.0);
r.size = Some(step(cur, up));
},
)
}
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -227,6 +235,9 @@ pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
return Err(bad("format.highlight", "`color` is not a valid colour"));
}
format_run(s, &move |r| r.highlight = c.clone())
}
+8 -1
View File
@@ -45,12 +45,17 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
if explicit_char {
para.props.bullet_font = Some("Arial".into());
para.props.bullet_font_follow_text = None;
}
if col.is_some() {
para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_follow_text = None;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
@@ -76,9 +81,11 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
if col.is_some() {
para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_follow_text = None;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
para.props.indent = Some(-27.0);
+16 -3
View File
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
use super::{CommandSpec, bad, cmd, has_slide, opt_usize_param, str_param, xfrm_of};
use crate::{Result, Selection, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -245,13 +245,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
let i = opt_usize_param(p, "slide", "design.ideas")?.unwrap_or(st.selection.slide);
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
}
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
let slide = usize_param(p, "slide");
let slide = opt_usize_param(p, "slide", "design.applyIdea")?;
s.edit(|doc, sel| {
let i = slide.unwrap_or(sel.slide);
apply_idea(doc, sel, i, &id)?;
@@ -372,6 +372,19 @@ mod tests {
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
}
#[test]
fn an_invalid_slide_index_is_refused_not_replaced_by_the_selection() {
let mut s = deck(false);
let before = s.doc().unwrap().doc.clone();
for bad_slide in [json!(-1), json!(1.5), json!("0")] {
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": bad_slide})).is_err(), "{bad_slide}");
assert!(s.execute("design.ideas", &json!({"slide": bad_slide})).is_err(), "{bad_slide}");
}
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc) || *before == *s.doc().unwrap().doc);
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": 0})).is_ok());
assert!(s.execute("design.ideas", &json!({})).is_ok());
}
#[test]
fn switching_from_full_bleed_removes_the_title_band() {
let mut s = deck(true);
+82 -6
View File
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Date & Time",
["Insert", "Text"],
None,
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog",
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"",
has_slide,
date_time
),
@@ -187,9 +187,10 @@ pub(crate) fn auto_name_base(shape: &Shape) -> String {
}
/// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses,
/// so a gap left by a deleted shape never makes a second shape with the same name.
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>) -> String {
(taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
/// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the
/// number of shapes, which exceeds the number of names when two shapes share one.
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>, count: usize) -> String {
(count + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
}
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
@@ -197,8 +198,8 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
if shape.name.is_empty() {
let base = auto_name_base(&shape);
let taken = super::current_names(doc, sel);
shape.name = first_free_name(&base, &taken);
let (taken, count) = super::current_names(doc, sel);
shape.name = first_free_name(&base, &taken, count);
}
let id = shape.id;
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
@@ -449,6 +450,17 @@ mod tests {
assert_eq!(undo(&s), before + 1);
}
#[test]
fn a_long_british_date_puts_the_day_first() {
let mut s = session();
edit_title(&mut s);
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-GB"})).unwrap();
let text = v["text"].as_str().unwrap().to_string();
assert!(text.chars().next().is_some_and(|c| c.is_ascii_digit()) && !text.contains(','), "{text}");
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-US"})).unwrap();
assert!(v["text"].as_str().is_some_and(|t| t.contains(',')), "{v}");
}
#[test]
fn a_named_format_in_french_and_a_bad_value_change_nothing() {
let mut s = session();
@@ -473,6 +485,70 @@ mod tests {
}
}
/// ZZ1: in the handout and notes master views the date goes into the master's date placeholder
/// (not the current slide), so the exported handout and notes pages show it.
#[test]
fn on_a_page_master_the_date_fills_its_date_placeholder() {
for view in ["view.handoutMaster", "view.notesMaster"] {
let mut s = session();
s.execute(view, &json!({})).unwrap();
let (id, master) = {
let st = s.doc().unwrap();
let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone();
(m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m)
};
assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()));
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap();
assert_eq!(v["where"], "master", "{view}: {v}");
assert!(date_run(&s).is_none(), "the slide keeps no date");
let st = s.doc().unwrap();
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
let run = m
.shapes
.iter()
.find(|x| x.id.0 == id)
.and_then(|x| x.text.as_ref())
.and_then(|t| t.paragraphs.first())
.and_then(|p| p.runs.first())
.cloned()
.unwrap();
assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}");
let placed = deckcraft_render::place_master_texts(&st.doc, m, 1);
assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date");
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo");
// A fixed date in a placeholder whose run is en-GB reads day first, and the answer
// reports the text that was written, not the en-US sample.
s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap();
let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap();
let st = s.doc().unwrap();
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
let run = m
.shapes
.iter()
.find(|x| x.id.0 == id)
.and_then(|x| x.text.as_ref())
.and_then(|t| t.paragraphs.first())
.and_then(|p| p.runs.first())
.cloned()
.unwrap();
assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}");
assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}");
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now);
assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)");
// No date placeholder: refused, nothing changed.
let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" };
s.execute(key, &json!({"date": false})).unwrap();
let rev = s.doc().unwrap().revision;
assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err());
assert_eq!(s.doc().unwrap().revision, rev);
}
}
#[test]
fn without_a_caret_the_date_lands_on_this_slide_only() {
let mut s = session();
@@ -152,6 +152,11 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
}
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
}
// Handout or Notes Master view: the master's date placeholder (the selected one, else the
// master's own), which every handout or notes page then shows.
if s.doc()?.selection.target.is_page_master() {
return write_master_date(s, o);
}
// Not editing: the slide's date placeholder (this slide only). A layout with no date
// placeholder gets a text box instead, so the chosen format is still inserted.
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
@@ -161,6 +166,49 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
}
/// Put the date in the date placeholder of the handout or notes master being edited.
fn write_master_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update);
let has_date = {
let st = s.doc()?;
crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)))
};
if !has_date {
return Err(bad(
"insert.dateTime",
"this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}",
));
}
// The text the master holds afterwards, which the answer reports (it can differ from the
// caller's sample when the placeholder's own language formats a fixed date).
let mut written = o.sample.clone();
s.edit(|doc, sel| {
let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) };
let m = std::sync::Arc::make_mut(m);
let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date);
let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x)));
let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) };
let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) };
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
// A fixed date reads in the placeholder's own language when the caller named none.
let text = match (&lang, &props.lang) {
(None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()),
_ => sample.clone(),
};
if let Some(lang) = lang {
props.lang = Some(lang);
}
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
let body = sh.text.clone().unwrap_or_default();
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
written.clone_from(&text);
para.runs = vec![deckcraft_model::Run { text, props, kind }];
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
Ok(true)
})?;
Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written}))
}
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
let format = o.format.clone();
+146 -1
View File
@@ -1,16 +1,18 @@
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::Hyperlink;
use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
]
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
let mut found = Vec::new();
for sh in &sl.shapes {
collect_actions(sh, &mut Vec::new(), &mut found);
}
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
}
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
path.push(json!({"id": sh.id, "name": sh.name}));
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
if let Some(link) = &sh.click {
found.push(action_entry(owner.clone(), "click", link));
}
if let Some(link) = &sh.hover {
found.push(action_entry(owner.clone(), "hover", link));
}
if let Some(text) = &sh.text {
collect_text_actions(text, &owner, found);
}
if let ShapeKind::Table(table) = &sh.kind {
for (row_index, row) in table.rows.iter().enumerate() {
for (cell_index, cell) in row.cells.iter().enumerate() {
let mut cell_owner = owner.clone();
cell_owner["tableRowIndex"] = json!(row_index);
cell_owner["tableCellIndex"] = json!(cell_index);
collect_text_actions(&cell.text, &cell_owner, found);
}
}
}
if let ShapeKind::Group { children, .. } = &sh.kind {
for child in children {
collect_actions(child, path, found);
}
}
path.pop();
}
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
for (run_index, run) in paragraph.runs.iter().enumerate() {
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
if let Some(link) = link {
let mut text_owner = owner.clone();
text_owner["paragraphIndex"] = json!(paragraph_index);
text_owner["runIndex"] = json!(run_index);
text_owner["text"] = json!(run.content());
found.push(action_entry(text_owner, event, link));
}
}
}
}
}
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
let action = serde_json::to_value(&link.action).unwrap_or_default();
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
}
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
.collect(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_geom::Xfrm;
use deckcraft_model::table::Row;
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
fn link(action: Action) -> Hyperlink {
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
}
#[test]
fn slide_actions_lists_group_shape_and_text_actions() {
let mut s = Session::with_new();
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
child.click = Some(link(Action::NextSlide));
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
let mut paragraph = Paragraph::new("linked");
paragraph.runs = vec![Run::with("linked", run_props)];
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
let group = Shape {
id: ShapeId(11),
name: "Group".into(),
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
..Default::default()
};
let st = s.doc_mut().unwrap();
let slide_index = st.selection.slide;
let presentation = std::sync::Arc::make_mut(&mut st.doc);
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
let v = s.execute("slide.actions", &json!({})).unwrap();
let actions = v["actions"].as_array().unwrap();
assert_eq!(actions.len(), 3, "{v}");
assert_eq!(actions[0]["event"], "click");
assert_eq!(actions[0]["type"], "nextSlide");
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
assert_eq!(actions[1]["event"], "hover");
assert_eq!(actions[1]["target"], "https://example.com/hover");
assert_eq!(actions[2]["event"], "textLink");
assert_eq!(actions[2]["owner"]["runIndex"], 0);
assert_eq!(actions[2]["target"], 7);
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
let mut cell_text = TextBody::from_text("cell link");
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
let table =
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
let st = s.doc_mut().unwrap();
let presentation = std::sync::Arc::make_mut(&mut st.doc);
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
let v = s.execute("slide.actions", &json!({})).unwrap();
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
assert_eq!(table_action["event"], "textHover");
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
assert_eq!(table_action["target"], "https://example.com/cell");
}
#[test]
fn slide_actions_returns_empty_array_for_slide_without_actions() {
let mut s = Session::with_new();
let v = s.execute("slide.actions", &json!({})).unwrap();
assert_eq!(v["actions"], json!([]));
}
#[test]
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
let mut s = Session::with_new();
for index in [json!(-1), json!("0"), json!(1.5)] {
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
}
}
}
+110 -12
View File
@@ -58,9 +58,17 @@ pub fn specs() -> Vec<CommandSpec> {
has_text,
insert_synonym
),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
cmd!(
"review.accept",
"Accept Revision",
["Review", "Compare"],
None,
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
comparing,
accept
),
cmd!(
noundo "review.reject",
"Reject Revision",
@@ -264,19 +272,31 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
_ => None,
};
if let Some(bg) = bg {
for sh in &sl.shapes {
let Some(t) = &sh.text else { continue };
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &para.runs.first().map(|r| r.props.clone()).unwrap_or_default());
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
let Some(t) = &sh.text else { return };
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
if fill.as_ref().is_some_and(|f| !f.is_none()) {
continue;
return;
}
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
if fg.contrast(bg) < 3.0 {
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
let low = t
.paragraphs
.iter()
.flat_map(|para| {
// A line break or blank run shows no glyphs, so its colour can't be hard to read.
para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map(
|run| {
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props);
deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg)
},
)
})
.find(|contrast| *contrast < 3.0);
if let Some(contrast) = low {
out.push(
json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}),
);
}
}
});
}
}
}
@@ -860,3 +880,81 @@ mod spelling_change_range_tests {
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
}
#[cfg(test)]
mod accessibility_contrast_tests {
use super::*;
use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps};
use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody};
use serde_json::json;
fn run(text: &str, color: Rgba) -> Run {
Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() })
}
fn text_shape(id: u32, name: &str, paragraphs: Vec<Paragraph>) -> Shape {
Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() }
}
#[test]
fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() {
let mut s = Session::with_new();
s.edit(|doc, _| {
let black = Rgba::BLACK;
let white = Rgba::WHITE;
let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() };
let later = vec![
Paragraph { runs: vec![run("visible", black)], ..Default::default() },
Paragraph { runs: vec![run("hidden", white)], ..Default::default() },
];
let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]);
let group = Shape {
id: ShapeId(4),
name: "group".into(),
descr: "group of text".into(),
kind: ShapeKind::Group {
children: vec![Shape {
id: ShapeId(7),
name: "nested group".into(),
descr: "nested group of text".into(),
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
..Default::default()
}],
child: Default::default(),
},
..Default::default()
};
let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?);
sl.shapes = vec![
text_shape(2, "mixed", vec![mixed]),
text_shape(3, "later paragraph", later),
group,
text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]),
text_shape(
8,
"invisible white runs",
vec![Paragraph {
runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)],
..Default::default()
}],
),
];
Ok(())
})
.unwrap();
let issues = s.execute("review.accessibility", &json!({})).unwrap();
let contrasts: Vec<_> = issues
.as_array()
.unwrap()
.iter()
.filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast"))
.collect();
assert_eq!(contrasts.len(), 3, "{issues}");
for id in [2, 3, 5] {
assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}");
}
assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}");
assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}");
}
}
+137 -32
View File
@@ -4,6 +4,8 @@
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
use std::borrow::Cow;
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
const MAX_HTML: usize = 1 << 20;
@@ -14,20 +16,51 @@ const MAX_RUNS: usize = 10_000;
pub fn body_to_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
if i > 0 {
out.push_str("<br>");
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
if !push_if_fits(&mut out, prefix, 4) {
return plain_html(body);
}
out.push_str("<p>");
for r in &p.runs {
let (open, close) = tags(&r.props);
out.push_str(&open);
if !push_if_fits(&mut out, &open, close.len() + 4) {
return plain_html(body);
}
match &r.kind {
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
_ => push_escaped(&mut out, &r.text),
deckcraft_model::text::RunKind::Break => {
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
return plain_html(body);
}
}
_ => {
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
return plain_html(body);
}
}
}
out.push_str(&close);
if out.len() > MAX_HTML {
break;
}
out.push_str("</p>");
}
out
}
/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a
/// cut-off prefix would paste as if it were the whole text.
fn plain_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
if !push_if_fits(&mut out, prefix, 4) {
return String::new();
}
for r in &p.runs {
let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
push_if_fits(&mut out, "<br>", 4)
} else {
push_escaped_bounded(&mut out, &r.text, 4)
};
if !fits {
return String::new();
}
}
out.push_str("</p>");
@@ -35,6 +68,43 @@ pub fn body_to_html(body: &TextBody) -> String {
out
}
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
return false;
}
out.push_str(value);
true
}
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
for c in text.chars() {
match c {
'&' => {
if !push_if_fits(out, "&amp;", reserve) {
return false;
}
}
'<' => {
if !push_if_fits(out, "&lt;", reserve) {
return false;
}
}
'>' => {
if !push_if_fits(out, "&gt;", reserve) {
return false;
}
}
_ => {
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
return false;
}
out.push(c);
}
}
}
true
}
fn tags(props: &RunProps) -> (String, String) {
let mut open = String::new();
let mut close = String::new();
@@ -53,17 +123,6 @@ fn tags(props: &RunProps) -> (String, String) {
(open, close)
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
}
/// The formatted text of an HTML fragment, or `None` when it has no text.
///
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
@@ -77,7 +136,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
let mut bold = 0i32;
let mut italic = 0i32;
let mut underline = 0i32;
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
let mut stack: Vec<(Cow<'static, str>, i32, i32, i32)> = Vec::new();
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
let mut runs = 0usize;
let mut skip = 0i32;
@@ -106,23 +165,27 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
continue;
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty() {
let search_start = stack.len().saturating_sub(64);
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
let pos = search_start + offset;
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
let kind = match tag_kind(&name) {
"" => name.as_str(),
kind => kind,
};
let search_start = stack.len().saturating_sub(64);
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0.as_ref() == kind) {
let pos = search_start + offset;
let (db, di, du) = (stack[pos].1, stack[pos].2, stack[pos].3);
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
if is_block(&name) {
new_para(&mut paragraphs);
}
continue;
}
if is_block(&name) {
new_para(&mut paragraphs);
}
if is_break(&name) {
new_para(&mut paragraphs);
continue;
@@ -133,7 +196,11 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
italic += di;
underline += du;
if !token.ends_with('/') && stack.len() < 128 {
stack.push((tag_kind(&name), db, di, du));
let kind = match tag_kind(&name) {
"" => Cow::Owned(name.clone()),
kind => Cow::Borrowed(kind),
};
stack.push((kind, db, di, du));
} else {
bold -= db;
italic -= di;
@@ -368,6 +435,34 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn style_on_other_tags_ends_at_their_close() {
type Prop = fn(&RunProps) -> bool;
let cases: [(&str, &str, Prop); 3] = [
("a", "font-weight:bold", |p| p.bold == Some(true)),
("td", "font-style:italic", |p| p.italic == Some(true)),
("mark", "text-decoration:underline", |p| p.underline.is_some()),
];
for (tag, style, prop) in cases {
let body = html_to_body(&format!("<p><{tag} style=\"{style}\">link</{tag}> plain</p>")).unwrap();
let runs = &body.paragraphs[0].runs;
assert_eq!(runs.len(), 2, "{tag}");
assert!(prop(&runs[0].props), "{tag}");
assert_eq!(runs[0].text, "link");
assert_eq!(runs[1].text, " plain");
assert!(runs[1].props.bold.is_none() && runs[1].props.italic.is_none() && runs[1].props.underline.is_none(), "{tag}");
}
}
#[test]
fn text_before_opening_block_tag_is_separate_paragraph() {
for tag in ["div", "p", "li", "h1"] {
let body = html_to_body(&format!("Hello<{tag}>world</{tag}>")).unwrap();
let texts: Vec<String> = body.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect()).collect();
assert_eq!(texts, ["Hello", "world"], "{tag}");
}
}
#[test]
fn multibyte_after_ampersand_is_kept() {
let text = "é".repeat(20);
@@ -436,6 +531,16 @@ mod tests {
assert!(last.props.underline.is_some());
}
#[test]
fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() {
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
body.paragraphs[0].runs[0].props.bold = Some(true);
body.paragraphs.push(Paragraph::new("later paragraph"));
// Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text.
assert!(body_to_html(&body).is_empty());
}
#[test]
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
let text = |html: &str| html_to_body(html).unwrap().text();
+111 -3
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Effects",
["Shape Format", "Shape Styles"],
None,
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, size?: 0.01..2 (1 = 100%), blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
has_selection,
effects
),
@@ -165,7 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
picture_adjust
),
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
cmd!(
"picture.reset",
"Reset Picture",
["Picture Format", "Adjust"],
None,
"{ids?} (discards the picture's formatting: adjustments, artistic effect, crop, transparency, picture-style effects and outline; the frame shape from Change Shape or Crop to Shape, the size and the position stay)",
has_selection,
picture_reset
),
cmd!(
"picture.change",
"Change Picture",
@@ -253,6 +261,9 @@ fn requested_fill(s: &mut Session, p: &Value) -> Result<Option<Option<Fill>>> {
if bool_or(p, "none", false) {
return Ok(Some(Some(Fill::None)));
}
if bool_or(p, "background", false) {
return Ok(Some(Some(Fill::Background)));
}
if let Some(c) = color_param(p, "color") {
return Ok(Some(Some(Fill::solid(c))));
}
@@ -495,6 +506,103 @@ mod tests {
}
}
#[test]
fn background_fill_shows_the_slide_background_and_survives_a_save() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("design.background", &json!({"color": "#0000c8"})).unwrap();
let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32);
let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone();
assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill");
s.execute("shape.fill", &json!({"background": true})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill");
s.execute("edit.redo", &json!({})).unwrap();
// Transparency changes a solid or picture fill only.
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count();
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
}
/// A picture cannot save a slide background fill, so `background` leaves its fill alone.
#[test]
fn background_fill_skips_pictures() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
let pic = s
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]}))
.unwrap()["id"]
.as_u64()
.unwrap();
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err();
assert!(err.to_string().contains("shapes and text boxes"), "{err}");
assert_eq!(fill(&s, pic), None);
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.fill", &json!({"background": true})).unwrap();
assert_eq!(fill(&s, shape), Some(Fill::Background));
assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill");
assert_eq!(fill(&s, group), None, "the group's own fill is cleared");
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
let mut filled = 0;
for slide in &back.slides {
deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background)));
}
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
}
/// A picture filled with its group's fill keeps that fill behind its transparent pixels when
/// the group's shapes take the slide background fill.
#[test]
fn background_fill_keeps_the_group_fill_a_picture_uses() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png();
let pic = s
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0))));
s.edit(|doc, _| {
for slide in &mut doc.slides {
let slide = std::sync::Arc::make_mut(slide);
if let Some(g) = slide.shape_mut(ShapeId(group as u32)) {
g.fill.clone_from(&red);
}
if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) {
p.fill = Some(Fill::Group);
}
}
Ok(())
})
.unwrap();
let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone();
assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture");
s.execute("edit.select", &json!({"ids": [group]})).unwrap();
s.execute("shape.fill", &json!({"background": true})).unwrap();
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
assert_eq!(fill(&s, shape), Some(Fill::Background));
assert_eq!(fill(&s, pic), Some(Fill::Group));
assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses");
assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture");
}
fn rotation(s: &Session) -> f64 {
let st = s.active().unwrap();
let id = st.selection.shapes[0];
+63 -4
View File
@@ -1,7 +1,7 @@
use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
@@ -108,7 +108,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
scaled: false,
},
};
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
Some(Gradient {
tile: [0.0; 4],
stops,
shape,
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
})
}
/// Fill and outline apply to a group's visible members as well as its container. Other edits
@@ -134,11 +140,47 @@ pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let transparency = f64_param(p, "transparency");
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
return Err(bad(
"shape.fill",
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
));
}
// Transparency on an inherited (style) fill needs the effective colour.
let inherited = inherited_fills(s, p)?;
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
let background = matches!(new_fill, Some(Some(Fill::Background)));
if background {
let st = s.doc()?;
let mut any = false;
for id in targets(s, p)? {
if let Some(sh) = st.shape(id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
}
}
if !any {
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
}
}
edit_group_appearance(s, p, "shape.fill", |sh| {
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
sh.fill = None;
}
return Ok(());
}
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
})
}
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
/// of them is filled with it (`grpFill`).
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
depth <= 64
&& sh.children().iter().any(|c| match c.kind {
deckcraft_model::ShapeKind::Shape => false,
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
_ => c.fill == Some(Fill::Group),
})
}
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
@@ -401,6 +443,12 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = num(v, "dir") {
x.dir = d.rem_euclid(360.0);
}
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
if let Some(z) = num(v, "size") {
let z = z.clamp(0.01, 2.0);
x.sx = z;
x.sy = z;
}
*slot = Some(x);
}
if let Some(v) = &glow_props {
@@ -513,3 +561,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gradient_parameters_keep_flip_mode() {
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
}
}
+176 -23
View File
@@ -1,5 +1,6 @@
//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::text::TextBody;
@@ -22,7 +23,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
cmd!(
"slide.move",
"Move Slide",
[],
None,
"{from?: index, ids?: [slide id], to: index (counted without the moved slides)}",
has_slide,
move_slide
),
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
@@ -266,36 +275,75 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step.
/// `to` counts in the deck without the moved slides; they land there as one run, in the order
/// they had, join the section of the slide before them, and stay selected.
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
let ids = p.get("ids").filter(|v| !v.is_null());
let from = match ids {
None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide),
Some(_) => 0,
};
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
s.edit(|doc, sel| {
if from >= doc.slides.len() {
return Err(bad("slide.move", format!("no slide {from}")));
}
let sl = doc.slides.remove(from);
let to = to.min(doc.slides.len());
let id = sl.id;
doc.slides.insert(to, sl);
// Keep sections consistent with order: move the id into the section of its new neighbour.
if !doc.sections.is_empty() {
for sec in &mut doc.sections {
sec.slides.retain(|x| *x != id);
let moving: HashSet<SlideId> = match ids {
Some(v) => slide_ids_param(v, doc, "slide.move")?,
None => {
let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?;
HashSet::from([id])
}
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
if let Some(sec) = doc.sections.get_mut(si) {
sec.slides.push(id);
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
};
let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id));
let to = to.min(rest.len());
let tail = rest.split_off(to);
let moved: Vec<SlideId> = block.iter().map(|x| x.id).collect();
rest.extend(block);
rest.extend(tail);
doc.slides = rest;
reassign_sections(doc, &moved, to);
sel.slide = to;
sel.slides = vec![id];
Ok(json!({"index": to}))
sel.slides = moved;
sel.shapes.clear();
sel.text = None;
Ok(json!({"index": to, "moved": moving.len()}))
})
}
/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold).
/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck.
fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result<HashSet<SlideId>> {
let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?;
let known: HashSet<SlideId> = doc.slides.iter().map(|x| x.id).collect();
let mut out = HashSet::new();
for x in a {
let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id));
out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?);
}
if out.is_empty() {
return Err(bad(cmd, "`ids` is empty"));
}
Ok(out)
}
/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of
/// the slide just before it (the first section when it opens the deck), in document order.
fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) {
if doc.sections.is_empty() {
return;
}
let moving: HashSet<SlideId> = moved.iter().copied().collect();
for sec in &mut doc.sections {
sec.slides.retain(|x| !moving.contains(x));
}
let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
let order: HashMap<SlideId, usize> = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
if let Some(sec) = doc.sections.get_mut(si) {
sec.slides.extend_from_slice(moved);
sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX));
}
}
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
s.select(|doc, sel| {
let n = doc.slides.len();
@@ -710,6 +758,111 @@ mod tests {
}
}
/// A deck of `n` slides and its slide ids, in order.
fn deck(n: usize) -> (Session, Vec<u32>) {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < n {
s.execute("slide.new", &json!({})).expect("new slide");
}
let ids = order(&s);
(s, ids)
}
fn order(s: &Session) -> Vec<u32> {
s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect()
}
fn selected(s: &Session) -> (usize, Vec<u32>) {
let sel = &s.doc().expect("doc").selection;
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
}
#[test]
fn move_from_to_moves_one_slide() {
let (mut s, ids) = deck(4);
s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move");
assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]);
assert_eq!(selected(&s), (0, vec![ids[3]]));
}
#[test]
fn move_ids_lands_them_as_one_run_in_deck_order() {
let (mut s, ids) = deck(6);
// Listed backwards: they still land in the order they had.
s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front");
assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]);
assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected");
let (mut s, ids) = deck(6);
s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle");
assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]);
assert_eq!(selected(&s), (2, vec![ids[0], ids[2]]));
let (mut s, ids) = deck(6);
s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps");
assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]);
assert_eq!(selected(&s), (4, vec![ids[1], ids[3]]));
}
#[test]
fn moving_several_slides_is_one_undo_step() {
let (mut s, ids) = deck(5);
s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select");
let before = selected(&s);
s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move");
let after = order(&s);
assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]);
s.execute("edit.undo", &json!({})).expect("undo");
assert_eq!(order(&s), ids);
assert_eq!(selected(&s), before);
s.execute("edit.redo", &json!({})).expect("redo");
assert_eq!(order(&s), after);
}
#[test]
fn moved_slides_join_the_section_before_them() {
let (mut s, ids) = deck(5);
s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A");
s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B");
// A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2.
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move");
let sections =
|s: &Session| -> Vec<Vec<u32>> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() };
assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]);
// Move the whole of A away: A stays, empty; the run at the front joins the first section.
s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A");
assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]);
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front");
assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]);
let all: Vec<u32> = sections(&s).concat();
assert_eq!(all, order(&s), "sections follow slide order");
}
#[test]
fn bad_ids_are_rejected_and_change_nothing() {
let (mut s, ids) = deck(3);
let lots_of_unknown: Vec<u64> = (1_000_000..1_100_000).collect();
for p in [
json!({"ids": [], "to": 0}),
json!({"ids": [ids[0], 999_999], "to": 0}),
json!({"ids": [-1], "to": 0}),
json!({"ids": ["1"], "to": 0}),
json!({"ids": [1.5], "to": 0}),
json!({"ids": [u64::MAX], "to": 0}),
json!({"ids": ids[0], "to": 0}),
json!({"ids": lots_of_unknown, "to": 0}),
json!({"ids": [ids[1]]}),
] {
assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}");
assert_eq!(order(&s), ids, "{p}");
}
// Repeats fold into one.
let repeated = vec![ids[2]; 100_000];
s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats");
assert_eq!(order(&s), [ids[2], ids[0], ids[1]]);
assert_eq!(selected(&s), (0, vec![ids[2]]));
}
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
+1
View File
@@ -99,6 +99,7 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
tile: [0.0; 4],
flip: None,
}));
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
+33 -8
View File
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align Cell Text",
["Table Layout", "Alignment"],
None,
"{anchor: top|middle|bottom, cells?, id?}",
"{anchor?: top|middle|bottom (vertical; top when neither anchor nor align is given), align?: left|center|right|justify (horizontal, applied to every paragraph of the cells), cells?, id?}",
has_selection,
cell_align
),
@@ -535,18 +535,43 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
}
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::Anchor;
let a = match str_param(p, "anchor").unwrap_or("top") {
"middle" | "center" | "ctr" => Anchor::Middle,
"bottom" | "b" => Anchor::Bottom,
"top" | "t" => Anchor::Top,
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
use deckcraft_model::text::{Align, Anchor};
// A value that is not a string is refused, not taken as absent.
for k in ["align", "anchor"] {
if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) {
return Err(bad("table.cellAlign", format!("`{k}` is a name such as {}", if k == "align" { "left or center" } else { "top or middle" })));
}
}
// Without `align`, the vertical anchor is the whole command (top when not given).
let align = match str_param(p, "align") {
None => None,
Some("left" | "l") => Some(Align::Left),
Some("center" | "centre" | "ctr") => Some(Align::Center),
Some("right" | "r") => Some(Align::Right),
Some("justify" | "just") => Some(Align::Justify),
Some(o) => return Err(bad("table.cellAlign", format!("unknown align {o:?}; one of left, center, right, justify"))),
};
let anchor = match (str_param(p, "anchor"), align) {
(None, Some(_)) => None,
(a, _) => Some(match a.unwrap_or("top") {
"middle" | "center" | "ctr" => Anchor::Middle,
"bottom" | "b" => Anchor::Bottom,
"top" | "t" => Anchor::Top,
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
}),
};
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.anchor = Some(a);
if anchor.is_some() {
cell.anchor = anchor;
}
if let Some(a) = align {
for para in &mut cell.text.paragraphs {
para.props.align = Some(a);
}
}
}
}
Ok(())
+1 -1
View File
@@ -30,7 +30,7 @@ use editing::{edit, exit};
pub(crate) use editing::{insert_field, insert_text, paste_body};
pub use formatting::format_range;
use formatting::{fonts_used, replace_font};
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
use ime::{commit, compose};
pub(crate) use layout::ctx_for;
+4 -1
View File
@@ -142,7 +142,7 @@ pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typi
/// After a space or punctuation, correct the word just typed.
pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
if !s.prefs.autocorrect {
if !s.prefs.autocorrect && !(typing && s.prefs.ac_lists) {
return Ok(());
}
let st = s.doc()?;
@@ -160,6 +160,9 @@ pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
return Ok(());
}
}
if !s.prefs.autocorrect {
return Ok(());
}
let mut word_end = end - 1;
let mut start = word_end;
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
+123 -9
View File
@@ -8,22 +8,34 @@ use super::{
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
/// or sets the formatting for what is typed next at a word boundary.
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
format_run_levels(s, f, &|r, _| f(r))
}
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
pub(crate) fn format_run_levels(
s: &mut Session,
f: &(dyn Fn(&mut RunProps) + Send + Sync),
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
) -> Result<Value> {
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = format_range(st, &t);
let collapsed = a == b;
// Only the levels of the paragraphs the selected range touches.
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
edit_text(s, |body, _| {
if collapsed {
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
if levels.is_empty() {
ed::format(body, a, b, f);
} else {
ed::format(body, a, b, f);
format_by_level(body, a, b, lf, &levels);
}
for (l, eff) in &levels {
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
lf(&mut lvl.run, eff);
body.list_style.set(*l, lvl);
}
})?;
if collapsed {
// Remember as pending typing props by inserting nothing: handled via end mark for
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
}
return ok();
}
let ids = st.selection.shapes.clone();
@@ -31,6 +43,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
// Effective run props of every level of the selected placeholders, resolved before editing.
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
ids.iter()
.filter_map(|id| {
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
let ctx = ctx.as_ref()?;
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
})
.collect()
} else {
Vec::new()
};
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
match &mut sh.kind {
@@ -47,7 +72,22 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
}
_ => {
let body = sh.text.get_or_insert_with(TextBody::default);
ed::format_all(body, f);
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
match eff {
Some(eff) => {
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
}
None => ed::format_all(body, f),
}
// The whole shape is selected: every level of its list style.
if let Some(eff) = eff {
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
lf(&mut lvl.run, eff);
body.list_style.set(l, lvl);
}
}
}
}
Ok(())
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
ok()
}
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
/// value. `a`/`b` are clamped to the body.
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
let (first, end) = (a.0.min(last), b.0.min(last));
for pi in first..=end {
let p = &body.paragraphs[pi];
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
if from == to && p.char_len() > 0 {
continue;
}
let lvl = p.level.min(LIST_LEVELS - 1);
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
}
}
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
const LIST_LEVELS: u8 = 9;
/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style,
/// which is what the placeholders on slides inherit; its own runs may be few or none.
fn editing_master(st: &DocState) -> bool {
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
}
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
/// placeholder, with each level's effective run props. Empty anywhere else.
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
if !editing_master(st) || t.notes || t.cell.is_some() {
return Vec::new();
}
let (Some(sh), Some(body), Some(ctx)) =
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
else {
return Vec::new();
};
let mut levels: Vec<u8> =
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
if body.paragraphs.is_empty() {
levels.push(0);
}
levels.sort_unstable();
levels.dedup();
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
}
/// 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. Insert Hyperlink
/// edits this same span, so the dialog reads it too.
@@ -110,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered();
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
edit_text(s, |body, _| {
if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default());
@@ -120,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
f(p);
}
}
for l in levels {
format_list_level(body, l, f);
}
})?;
return ok();
}
@@ -128,8 +222,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
let on_master = editing_master(st);
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
let inherited = on_master && sh.ph.is_some();
match &mut sh.kind {
ShapeKind::Table(t) => {
let range = crate::cmd::table::selected_range(cells, id, t);
@@ -147,6 +243,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
body.paragraphs.push(Default::default());
}
body.paragraphs.iter_mut().for_each(f);
// The whole shape is selected: every level of its list style.
if inherited {
for l in 0..LIST_LEVELS {
format_list_level(body, l, f);
}
}
}
}
Ok(())
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
ok()
}
/// Apply a paragraph change to one level of a body's list style.
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
f(&mut p);
// A level change (indent/outdent) means nothing for a list-style level.
if p.level == l {
lvl.para = p.props;
body.list_style.set(l, lvl);
}
}
/// Apply body (text box) changes to the edited or selected shapes.
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
let st = s.doc()?;
+33 -7
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
use deckcraft_model::anim::TRANSITIONS;
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -44,9 +44,9 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn slide_indices(s: &Session, p: &Value) -> Result<Vec<usize>> {
fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let st = s.doc()?;
if let Some(i) = usize_param(p, "index") {
if let Some(i) = opt_usize_param(p, "index", cmd)? {
return Ok(vec![i]);
}
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
@@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?;
let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default();
let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32);
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.set")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
@@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string();
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.options")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i)
@@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
}
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.timing")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
@@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.sound")?;
let looping = bool_param(p, "loop");
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
s.edit(|doc, _| {
@@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result<Value> {
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect()))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("transition.set", json!({"kind": "fade", "index": bad})),
("transition.options", json!({"option": "x", "index": bad})),
("transition.timing", json!({"duration": 500, "index": bad})),
("transition.sound", json!({"none": true, "index": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}");
}
}
}
}
+221 -20
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::resolve::Ctx;
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
use serde::Serialize;
@@ -92,6 +93,12 @@ pub struct Change {
pub kind: ChangeKind,
/// Index in the open deck (for inserted: where it would go).
pub slide: usize,
/// The editor's number of that slide (`slide` + 1).
pub number: usize,
/// Which side has it alone: `deck` (the open presentation; accepting deletes it) or `file`
/// (the compared file; accepting adds it). Absent when both have it and differ.
#[serde(skip_serializing_if = "Option::is_none")]
pub only_in: Option<&'static str>,
/// Index in the revised deck (none for deleted).
pub revised: Option<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
@@ -118,19 +125,33 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
}
for (i, s) in cur.slides.iter().enumerate() {
if !cur_matched.contains(&i) {
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
out.push(Change {
kind: ChangeKind::Deleted,
slide: i,
number: i.saturating_add(1),
only_in: Some("deck"),
revised: None,
details: vec![slide_title(s)],
});
}
}
for (j, s) in rev.slides.iter().enumerate() {
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
number: at.saturating_add(1),
only_in: Some("file"),
revised: Some(j),
details: vec![slide_title(s)],
});
}
}
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
@@ -172,7 +193,18 @@ fn plain_shape(s: &Shape) -> Shape {
c
}
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
/// The box a shape is drawn with: its own, else the one it inherits from its layout or master
/// placeholder (`ctx`).
fn drawn_box(ctx: Option<&Ctx>, s: &Shape) -> Option<deckcraft_model::Xfrm> {
s.xfrm.or_else(|| ctx.map(|c| deckcraft_model::resolve::xfrm(c, s)))
}
/// The rotation and flips a shape is drawn with (none without a box).
fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
}
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -181,12 +213,24 @@ fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out
} else if a.text != b.text {
what.push("text formatting");
}
match (&a.xfrm, &b.xfrm) {
// Boxes as drawn: a stored box equal to the inherited one is no change.
match (&drawn_box(ca, a), &drawn_box(cb, b)) {
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
_ => {}
}
// Rotation and flips are reported alongside a move or resize, not hidden behind it. No stored
// box means the inherited one (the layout's or master's), as the shape is drawn.
let (ta, tb) = (turn(ca, a), turn(cb, b));
if ta.0 != tb.0 {
what.push("rotated");
}
if (ta.1, ta.2) != (tb.1, tb.2) {
what.push("flipped");
}
if a.geom != b.geom {
what.push("shape");
}
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
@@ -197,17 +241,24 @@ fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out
if pic(cp, a) != pic(rp, b) {
what.push("picture");
}
if what.is_empty() {
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
x.text = None;
y.text = None;
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
x.kind = ShapeKind::Shape;
y.kind = ShapeKind::Shape;
}
if x != y {
what.push("properties");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
// beside them: the fields they compared are left out of this comparison.
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
for z in [&mut x, &mut y] {
z.text = None;
z.xfrm = None;
z.geom = Default::default();
z.fill = None;
z.line = None;
z.style = None;
z.effects = Default::default();
}
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
x.kind = ShapeKind::Shape;
y.kind = ShapeKind::Shape;
}
if x != y {
what.push("properties");
}
if !what.is_empty() {
out.push(format!("{label}: {}", what.join(", ")));
@@ -217,6 +268,7 @@ fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
for sa in &a.shapes {
let k = b
.shapes
@@ -227,15 +279,15 @@ fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) ->
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, sa, rp, sb, &mut out);
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
}
}
_ => out.push(format!("{} deleted", shape_label(sa))),
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
}
}
for (k, sb) in b.shapes.iter().enumerate() {
if !used.contains(&k) {
out.push(format!("{} inserted", shape_label(sb)));
out.push(format!("{} inserted (only in the file)", shape_label(sb)));
}
}
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
@@ -354,6 +406,25 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
}
// A placeholder without a box of its own is drawn with its layout's; where this deck's layout
// draws it otherwise (placed or turned differently), the file's box is stored on it, so the
// accepted slide looks as the file does and compares equal to it.
if let (Some(file), Some(here)) = (Ctx::for_slide(rev, slide), Ctx::for_slide(doc, &sl)) {
let boxes: Vec<Option<deckcraft_model::Xfrm>> = slide
.shapes
.iter()
.zip(&sl.shapes)
.map(|(f, h)| {
let want = deckcraft_model::resolve::xfrm(&file, f);
(h.xfrm.is_none() && deckcraft_model::resolve::xfrm(&here, h) != want).then_some(want)
})
.collect();
for (h, b) in sl.shapes.iter_mut().zip(boxes) {
if b.is_some() {
h.xfrm = b;
}
}
}
match replacing {
Some(id) => sl.id = id,
None => {
@@ -494,6 +565,136 @@ mod tests {
assert_eq!(bytes(back.media), b"open background");
}
/// NN6: a slide reset drops the placeholders' stored boxes; comparing it with the copy from
/// before the reset must not call that a rotation (no stored box is rotation 0).
#[test]
fn a_reset_slide_is_not_rotated_against_its_earlier_copy() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "b"})).unwrap();
let ids: Vec<u32> = s.doc().unwrap().doc.slides[1].shapes.iter().map(|x| x.id.0).collect();
for id in &ids {
s.execute("shape.move", &json!({"ids": [id], "dx": 10, "dy": 0})).unwrap();
s.execute("shape.move", &json!({"ids": [id], "dx": -10, "dy": 0})).unwrap();
}
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("slide.reset", &json!({"index": 1})).unwrap();
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
assert!(!v.to_string().contains("rotated"), "{v}");
}
/// A shape both moved and rotated reports both, not only the move.
#[test]
fn a_moved_and_rotated_shape_reports_both() {
let mut s = Session::with_new();
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("shape.move", &json!({"ids": [id], "dx": 40, "dy": 0})).unwrap();
s.execute("shape.rotate", &json!({"ids": [id], "deg": 30})).unwrap();
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
assert!(v.contains("moved") && v.contains("rotated"), "{v}");
}
/// A property no other check covers (here the alt text) is reported beside a flip.
#[test]
fn a_flipped_shape_with_new_alt_text_reports_both() {
let mut s = Session::with_new();
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
let st = s.doc_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);
sl.shapes.iter_mut().find(|x| x.id.0 as u64 == id).unwrap().descr = "A red box".into();
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
assert!(v.contains("flipped") && v.contains("properties"), "{v}");
}
/// A flip and a change of shape (rectangle to ellipse) are both reported.
#[test]
fn a_flipped_shape_that_changed_shape_reports_both() {
let mut s = Session::with_new();
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
s.execute("shape.change", &json!({"ids": [id], "preset": "ellipse"})).unwrap();
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
assert!(v.contains("flipped") && v.contains("shape\""), "{v}");
}
/// A placeholder with no box of its own is drawn with its layout's rotation; setting it to 0
/// on the slide is a rotation change, and compare says so.
#[test]
fn an_inherited_rotation_counts_when_compared() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
s.execute("slide.reset", &json!({"index": 1})).unwrap();
let st = s.doc_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
let layout = doc.slides[1].layout;
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
let mut x = ph.xfrm.unwrap();
x.rot = 30.0;
ph.xfrm = Some(x);
assert!(doc.slides[1].shapes.iter().find(|x| title(x)).unwrap().xfrm.is_none(), "the title inherits its box");
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
let st = s.doc_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
let sl = std::sync::Arc::make_mut(&mut doc.slides[1]);
let t = sl.shapes.iter_mut().find(|x| title(x)).unwrap();
x.rot = 0.0;
t.xfrm = Some(x);
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
assert!(v.to_string().contains("rotated"), "{v}");
}
/// The file's placeholder inherits an unturned box from its layout while this deck's layout
/// turns it: accepting stores the file's box on the placeholder, so the slide is drawn as in the
/// file and nothing is left to accept.
#[test]
fn accepting_an_inherited_box_takes_the_files_box() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
s.execute("slide.reset", &json!({"index": 1})).unwrap();
let file = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
let st = s.doc_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut st.doc);
let layout = doc.slides[1].layout;
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
let mut x = ph.xfrm.unwrap();
x.rot = 30.0;
ph.xfrm = Some(x);
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
assert!(v.to_string().contains("rotated"), "{v}");
s.execute("review.accept", &json!({"all": true})).unwrap();
let st = s.doc().unwrap();
let t = st.doc.slides[1].shapes.iter().find(|x| title(x)).unwrap();
assert_eq!(t.xfrm.map(|x| x.rot), Some(0.0), "the file's box is stored: {:?}", t.xfrm);
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
assert!(v["changes"].as_array().is_some_and(|c| c.is_empty()), "nothing left to accept: {v}");
}
#[test]
fn each_change_says_which_side_it_is_on() {
let (mut s, rev, _) = decks();
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
for c in v["changes"].as_array().unwrap() {
assert_eq!(c["number"].as_u64().unwrap(), c["slide"].as_u64().unwrap() + 1, "{c}");
match c["kind"].as_str().unwrap() {
"deleted" => assert_eq!(c["onlyIn"], "deck", "{c}"),
"inserted" => assert_eq!(c["onlyIn"], "file", "{c}"),
_ => assert!(c.get("onlyIn").is_none(), "{c}"),
}
}
// A shape only in the open deck reads as only in the deck.
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
let v = s.execute("review.revisions", &json!({})).unwrap();
assert!(v.to_string().contains("deleted (only in the deck)"), "{v}");
}
#[test]
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
let (mut s, rev, n) = decks();
+41 -29
View File
@@ -35,8 +35,8 @@ pub enum Access {
/// The folders automation may read and write; a root left out grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
read: Vec<Root>,
write: Vec<Root>,
}
#[derive(Debug)]
@@ -62,46 +62,47 @@ impl Root {
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// The read and write roots, which must be existing folders; `None` when both are empty
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Option<Arc<Self>>, String> {
if read.is_empty() && write.is_empty() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
let read = read.iter().map(|p| Root::open(p, Access::Read)).collect::<Result<_, _>>()?;
let write = write.iter().map(|p| Root::open(p, Access::Write)).collect::<Result<_, _>>()?;
Ok(Some(Arc::new(Self { read, write })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
/// The read roots, absolute with their links followed.
pub fn read_roots(&self) -> impl Iterator<Item = &Path> {
self.read.iter().map(|r| r.shown.as_path())
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
/// The write roots, absolute with their links followed.
pub fn write_roots(&self) -> impl Iterator<Item = &Path> {
self.write.iter().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
let Some(Root { resolved: folder, shown }) = root else {
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
};
if inside(&resolve(path)?, folder) {
}
let resolved = resolve(path)?;
if roots.iter().any(|root| inside(&resolved, &root.resolved)) {
return Ok(());
}
let root = shown.display();
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
}
}
}
@@ -276,7 +277,7 @@ mod tests {
}
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[base.join("in")], &[base.join("out")]).unwrap().unwrap();
(base, roots)
}
@@ -300,6 +301,17 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn any_configured_root_grants_access() {
let (base, _) = folders("multiple");
let roots = AutomationRoots::new(&[base.join("in"), base.join("in2")], &[base.join("out"), base.join("outside")]).unwrap().unwrap();
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("out/new.deckcraft")), Access::Write), Ok(()));
assert_eq!(roots.check(&s(base.join("outside/new.deckcraft")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
@@ -336,18 +348,18 @@ mod tests {
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
let e = AutomationRoots::new(&[base.join("nope")], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
let e = AutomationRoots::new(&[], &[base.join("in/a.deckcraft")]).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
assert!(AutomationRoots::new(&[PathBuf::new()], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let read_only = AutomationRoots::new(&[base.join("in")], &[]).unwrap().unwrap();
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
@@ -384,7 +396,7 @@ mod tests {
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
let linked = AutomationRoots::new(&[base.join("in-link")], &[]).unwrap().unwrap();
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
+4 -2
View File
@@ -22,12 +22,14 @@ pub fn install_embedded_fonts(p: &Presentation) {
}
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). Names that
/// start with a dot are macOS hidden system fonts (`.SF NS`); they never appear in a font list
/// and can't be installed, so they aren't reported.
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
let db = FontDb::global();
let mut out: Vec<String> = Vec::new();
for family in used_fonts(p) {
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
out.push(family);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command —
//! Last-resort guard: a panic that escapes a command —
//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an
//! error, and the document is kept as it was before the command (and saved for crash recovery
//! when a recovery directory is set). It is a safety net: commands still must not panic.
+23 -1
View File
@@ -682,7 +682,28 @@ impl Session {
self.journal.drain(..1000);
}
}
Ok(r)
Ok(if outer { self.with_editing(id, r) } else { r })
}
/// In Slide Master / layout (or handout / notes master) view the insert commands edit the
/// master or layout, not a slide; their answer says so with `editing: {kind, name}`, as
/// `slide.inspect` does. Nothing is added on slides, so those answers stay as they were.
fn with_editing(&self, id: &str, mut r: Value) -> Value {
let inserts = id.starts_with("insert.") || matches!(id, "shape.insert" | "shape.freeform" | "shape.connect" | "slide.new");
let (true, Some(st), Some(o)) = (inserts, self.active(), r.as_object_mut()) else { return r };
let doc = &st.doc;
let edited = match st.selection.target {
Target::Master { master } => doc.masters.get(master).map(|m| ("master", m.name.clone())),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| ("layout", l.name.clone())),
t @ (Target::Handout | Target::NotesMaster) => {
page_master(doc, t).map(|m| (if matches!(t, Target::Handout) { "handout" } else { "notesMaster" }, m.name.clone()))
}
Target::Slides => None,
};
if let Some((kind, name)) = edited {
o.entry("editing").or_insert_with(|| serde_json::json!({"kind": kind, "name": name}));
}
r
}
pub fn commands(&self) -> Vec<CommandInfo> {
@@ -695,6 +716,7 @@ impl Session {
let mut doc = (*st.doc).clone();
let mut sel = st.selection.clone();
let r = f(&mut doc, &mut sel)?;
cmd::arrange::refit_groups(&st.doc, &mut doc);
connect::reroute(&mut doc, &sel);
st.doc = Arc::new(doc);
st.selection = sel;
+41 -3
View File
@@ -356,7 +356,14 @@ impl Dictionary {
}
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
/// Words longer than 100 characters are not checked (they pass).
pub fn check(&self, word: &str) -> bool {
// Compound splitting tries every split point, so checking costs O(n²) in the word's
// length: a 100,000-character "word" took seconds per check under a compounding
// dictionary, on every edit (#1175).
if word.chars().nth(MAX_CHECK_CHARS).is_some() {
return true;
}
if word.contains('\u{2019}') {
return self.check(&word.replace('\u{2019}', "'"));
}
@@ -394,12 +401,18 @@ impl Dictionary {
return vec![];
}
let cap = word.chars().next().is_some_and(char::is_uppercase);
let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() };
let upper = word.chars().count() > 1 && word.chars().any(char::is_uppercase) && !word.chars().any(char::is_lowercase);
let lower = if upper || (cap && word.chars().skip(1).all(|c| !c.is_uppercase())) { word.to_lowercase() } else { word.to_string() };
let w: Vec<char> = lower.chars().collect();
let mut out: Vec<String> = vec![];
let mut seen = HashSet::new();
let mut push = |s: String, out: &mut Vec<String>| {
if s != lower && seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
if s == lower {
return;
}
// Checked in capitals, so a KEEPCASE entry ("NASA") is offered for an all-caps word.
let s = if upper { s.to_uppercase() } else { s };
if seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
out.push(s);
}
};
@@ -443,7 +456,7 @@ impl Dictionary {
push(format!("{} {}", s(&w[..i]), s(&w[i..])), &mut out);
}
out.truncate(n);
if cap {
if cap && !upper {
out = out.into_iter().map(|x| capitalise(&x)).collect();
}
out
@@ -553,6 +566,9 @@ pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> {
/// Longest word `Dictionary::suggest` generates corrections for.
const MAX_SUGGEST_CHARS: usize = 64;
/// Longest word `Dictionary::check` checks, as Hunspell's word length limit.
const MAX_CHECK_CHARS: usize = 100;
type Cache = Mutex<HashMap<String, Option<Arc<Dictionary>>>>;
fn cache() -> &'static Cache {
@@ -843,6 +859,28 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
}
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
#[test]
fn a_word_longer_than_the_check_limit_is_not_checked() {
let d = Dictionary::parse(b"COMPOUNDFLAG X\nCOMPOUNDMIN 3\n", b"2\nfoot/X\nball/X\n".as_slice());
assert!(d.check("footballfoot"), "a compound is checked");
let under: String = "footb".repeat(MAX_CHECK_CHARS / 5);
assert_eq!(under.chars().count(), MAX_CHECK_CHARS);
assert!(!d.check(&under), "a word at the limit is still checked");
// Unchecked, not seconds of compound splitting.
assert!(d.check(&"footb".repeat(20_000)));
assert!(d.check(&format!("{under}x")));
}
#[test]
fn all_caps_words_get_uppercase_suggestions() {
let d = d();
assert!(d.suggest("SLDE", 5).contains(&"SLIDE".to_string()));
assert!(d.suggest("FOTO", 5).contains(&"PHOTO".to_string()));
assert!(d.suggest("NSSA", 5).contains(&"NASA".to_string()));
}
#[test]
fn compounds_join_flagged_parts() {
let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n";
+418
View File
@@ -237,6 +237,132 @@ fn pasted_shapes_get_the_next_automatic_name() {
assert_eq!(names(&s).last().unwrap(), "Hero");
}
/// PowerPoint 16.0 (Windows): on "Rectangle 1, Oval 2, TextBox 3, Hero" Duplicate gives
/// "Rectangle 5" and then "Rectangle 6", "TextBox 7"; the copy of "Hero" (and of its copy) is
/// "Hero". Never "Hero copy".
#[test]
fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() {
let mut s = session();
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let add = |s: &mut Session, cmd: &str, p: serde_json::Value| {
s.execute(cmd, &p).unwrap();
s.doc().unwrap().selection.shapes[0].0
};
let rect = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [10, 10, 50, 50]}));
let tb = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [100, 10, 50, 50]}));
s.execute("shape.rename", &json!({"id": tb, "name": "Hero"})).unwrap();
let n = names(&s).len();
let auto = names(&s)[n - 2].clone();
let (base, _) = auto.rsplit_once(' ').unwrap();
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
let all = names(&s);
assert_eq!(all[n], format!("{base} {}", n + 1), "{all:?}");
assert_eq!(all[n + 1], format!("{base} {}", n + 2), "{all:?}");
s.execute("edit.duplicate", &json!({"ids": [tb]})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0].0;
s.execute("edit.duplicate", &json!({"ids": [copy]})).unwrap();
let all = names(&s);
assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}");
}
/// Two shapes sharing a chosen name are still two shapes: the next number follows the count.
#[test]
fn duplicate_numbers_from_the_shape_count_when_names_repeat() {
let mut s = session();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
let rect = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 10, 50, 50]})).unwrap();
let hero = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.rename", &json!({"id": hero, "name": "Hero"})).unwrap();
s.execute("edit.duplicate", &json!({"ids": [hero]})).unwrap();
let before = s.doc().unwrap().current_slide().unwrap().shapes.len();
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
let doc = s.doc().unwrap();
let shapes = &doc.current_slide().unwrap().shapes;
let source = shapes.iter().find(|x| x.id.0 == rect).unwrap();
let base = source.name.rsplit_once(' ').unwrap().0;
let names: Vec<_> = shapes.iter().map(|x| x.name.as_str()).collect();
assert_eq!(shapes[before].name, format!("{base} {}", before + 1), "{names:?}");
}
/// The next automatic name looks at the names inside groups too.
#[test]
fn duplicating_a_group_does_not_repeat_the_names_of_its_members() {
let mut s = session();
let mut ids = vec![];
for x in [10, 70, 130, 190] {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
ids.push(s.doc().unwrap().selection.shapes[0].0);
}
s.execute("arrange.group", &json!({"ids": [ids[2], ids[3]]})).unwrap();
let group = s.doc().unwrap().selection.shapes[0].0;
// Fewer top-level shapes than names in use: the next number is one a member already has.
s.execute("edit.delete", &json!({"ids": [ids[0], ids[1]]})).unwrap();
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
let doc = s.doc().unwrap();
let mut all = vec![];
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
/// A shape inserted or pasted after a group was duplicated takes none of its members' names.
#[test]
fn inserting_after_duplicating_a_group_does_not_repeat_a_member_name() {
let mut s = session();
let mut ids = vec![];
for x in [10, 70] {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
ids.push(s.doc().unwrap().selection.shapes[0].0);
}
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
let group = s.doc().unwrap().selection.shapes[0].0;
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
// Fewer top-level shapes than shapes: counting only the top level reaches a member's number.
for x in [130, 190, 250] {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
}
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = s.doc().unwrap();
let mut all = vec![];
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
/// Group members are renamed like any other copied shape.
#[test]
fn duplicating_a_group_renames_its_automatic_children() {
let mut s = session();
let mut ids = vec![];
for x in [10, 100] {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
ids.push(s.doc().unwrap().selection.shapes[0].0);
}
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
let group = s.doc().unwrap().selection.shapes[0].0;
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
let doc = s.doc().unwrap();
let shapes = &doc.current_slide().unwrap().shapes;
let (orig, copy) = (&shapes[shapes.len() - 2], &shapes[shapes.len() - 1]);
assert!(orig.is_group() && copy.is_group());
assert_ne!(orig.name, copy.name);
assert!(copy.name.starts_with("Group "), "{}", copy.name);
let all: Vec<_> = orig.children().iter().chain(copy.children()).map(|c| c.name.as_str()).collect();
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
let mut s = session();
@@ -1425,6 +1551,14 @@ fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
assert!(s.doc().unwrap().missing_fonts.is_empty());
}
#[test]
fn a_macos_hidden_system_font_is_not_reported_missing() {
let mut s = Session::with_new();
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font(".SF NS")})).unwrap();
assert_eq!(r["missingFonts"], json!([]));
assert!(s.doc().unwrap().missing_fonts.is_empty());
}
#[test]
fn an_embedded_face_is_used_when_the_machine_lacks_it() {
let mut p = deckcraft_model::Presentation::default();
@@ -2320,6 +2454,15 @@ fn shape_effects_props_adjust_shadow_glow_and_reflection() {
let f = fx(&s);
assert!(f.outer_shadow.is_none());
assert_eq!(f.inner_shadow.unwrap().blur, 3.0);
// Size scales both axes (PowerPoint's 0–200%); out-of-range values clamp.
s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap();
s.execute("shape.effects", &json!({"shadowProps": {"size": 1.5}})).unwrap();
let sw = fx(&s).inner_shadow.unwrap();
assert_eq!((sw.sx, sw.sy), (1.5, 1.5));
s.execute("shape.effects", &json!({"shadowProps": {"size": 9}})).unwrap();
assert_eq!(fx(&s).inner_shadow.unwrap().sx, 2.0);
s.execute("shape.effects", &json!({"shadowProps": {"size": 0}})).unwrap();
assert_eq!(fx(&s).inner_shadow.unwrap().sy, 0.01);
// Glow: size, colour, transparency; size 0 removes it.
s.execute("shape.effects", &json!({"glowProps": {"radius": 12, "transparency": 0.5}})).unwrap();
let g = fx(&s).glow.unwrap();
@@ -3055,6 +3198,15 @@ fn motion_path_presets_have_paths_ids_and_round_trip() {
fn numbering_takes_color_and_size() {
let mut s = Session::with_new();
s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap();
{
let id = s.doc().unwrap().selection.shapes[0];
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let shape = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut d.slides[0]).shapes, id).unwrap();
let props = &mut shape.text.as_mut().unwrap().paragraphs[0].props;
props.bullet_font_follow_text = Some(true);
props.bullet_color_follow_text = Some(true);
props.bullet_size_follow_text = Some(true);
}
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
let para = |s: &Session| {
let st = s.doc().unwrap();
@@ -3064,9 +3216,13 @@ fn numbering_takes_color_and_size() {
let pp = para(&s);
assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod"));
assert_eq!(pp.bullet_size, Some(1.5));
assert_eq!(pp.bullet_font_follow_text, Some(true));
assert_eq!(pp.bullet_color_follow_text, None);
assert_eq!(pp.bullet_size_follow_text, None);
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
assert_eq!(para(&s).bullet_size, Some(4.0));
assert_eq!(para(&s).bullet_font_follow_text, None);
}
#[test]
@@ -4421,6 +4577,16 @@ fn autoformat_starts_bulleted_and_numbered_lists_as_you_type() {
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["lists"], true);
s.execute("text.autocorrect", &json!({"lists": false})).unwrap();
assert_eq!(typed(&mut s, "* x"), ("* x".to_string(), none));
// List AutoFormat is independent of the Replace text as you type setting.
s.execute("text.autocorrect", &json!({"on": false, "lists": true})).unwrap();
assert_eq!(typed(&mut s, "*"), ("*".to_string(), Some(Bullet::None)));
s.execute("text.insert", &json!({"text": " "})).unwrap();
assert_eq!(para(&s), (String::new(), ch("•")));
s.execute("text.delete", &json!({"dir": "backward"})).unwrap();
assert_eq!(para(&s), ("* ".to_string(), Some(Bullet::None)));
// Word replacements still run while list formatting is disabled.
s.execute("text.autocorrect", &json!({"on": true, "lists": false})).unwrap();
assert_eq!(typed(&mut s, "teh "), ("The ".to_string(), Some(Bullet::None)));
}
#[test]
@@ -5646,3 +5812,255 @@ fn invalid_ids_array_never_falls_back_to_the_selection() {
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
}
}
#[test]
fn table_cell_align_sets_the_horizontal_alignment() {
use deckcraft_model::text::{Align, Anchor};
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
s.execute("table.cellAlign", &json!({"id": id.0, "align": "center", "cells": [[0, 0], [1, 0]]})).unwrap();
s.execute("table.cellAlign", &json!({"id": id.0, "align": "right", "anchor": "bottom", "cells": [[0, 1]]})).unwrap();
let t = table_of(&s, id);
let al = |r, c| t.cell(r, c).unwrap().text.paragraphs.iter().map(|p| p.props.align).collect::<Vec<_>>();
assert_eq!(al(0, 0), vec![Some(Align::Center)]);
assert_eq!(al(1, 0), vec![Some(Align::Center)]);
assert_eq!(al(0, 1), vec![Some(Align::Right)]);
assert_eq!(al(1, 1), vec![None], "other cells are untouched");
assert_eq!(t.cell(0, 1).unwrap().anchor, Some(Anchor::Bottom));
assert_eq!(t.cell(0, 0).unwrap().anchor, None, "align alone leaves the vertical anchor");
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "diagonal"})).is_err());
// A value that is not a name is refused, not read as absent (which would set the anchor to top).
let rev = s.doc().unwrap().revision;
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": 1})).is_err());
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "left", "anchor": true})).is_err());
assert_eq!(s.doc().unwrap().revision, rev);
}
/// In Slide Master / layout view the insert commands edit the master or layout; their answers say
/// so with `editing`, and on slides carry no such key.
#[test]
fn insert_answers_carry_editing_in_master_and_layout_view() {
let mut s = session();
let ins = json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]});
s.execute("view.slideMaster", &json!({"layout": 0})).unwrap();
let r = s.execute("shape.insert", &ins).unwrap();
assert_eq!(r["editing"]["kind"], "layout", "{r}");
assert!(r["editing"]["name"].is_string(), "{r}");
let r = s.execute("insert.textBox", &json!({"rect": [10.0, 10.0, 100.0, 50.0], "text": "x"})).unwrap();
assert_eq!(r["editing"]["kind"], "layout", "{r}");
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
let r = s.execute("shape.insert", &ins).unwrap();
assert_eq!(r["editing"]["kind"], "master", "{r}");
s.execute("view.closeMaster", &json!({})).unwrap();
let r = s.execute("shape.insert", &ins).unwrap();
assert!(r.get("editing").is_none(), "{r}");
}
/// MM4: moving a group's child through a command refits the group's box to its children, as
/// PowerPoint does, and the children stay where they were drawn.
#[test]
fn moving_a_group_child_refits_the_group() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap();
let a = ins(&mut s, json!({"preset": "rect", "rect": [100, 100, 100, 50]}));
let b = ins(&mut s, json!({"preset": "rect", "rect": [300, 100, 100, 50]}));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let gb = |s: &Session| s.doc().unwrap().shape(ShapeId(g as u32)).unwrap().xfrm.unwrap();
assert_eq!((gb(&s).x, gb(&s).w), (100.0, 300.0));
s.execute("shape.move", &json!({"id": b, "dx": 100, "dy": 60})).unwrap();
let x = gb(&s);
assert_eq!((x.x, x.y, x.w, x.h), (100.0, 100.0, 400.0, 110.0), "group follows its children: {x:?}");
// The child is where it was moved to on the slide.
let r = s.execute("slide.inspect", &json!({})).unwrap();
assert!(r.to_string().contains("400"), "{r}");
s.execute("shape.setBounds", &json!({"ids": [a], "x": 20, "y": 20, "w": 100, "h": 50})).unwrap();
let x = gb(&s);
assert_eq!((x.x, x.y, x.w), (20.0, 20.0, 480.0), "{x:?}");
}
/// HH9: Reset Picture discards the picture's formatting (adjustments, artistic effect, picture
/// style effects and outline) but not the frame's shape (Change Shape / Crop to Shape).
#[test]
fn picture_reset_keeps_the_frame_shape() {
let mut img = image::RgbaImage::new(40, 30);
img.pixels_mut().for_each(|p| *p = image::Rgba([200, 30, 30, 255]));
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap();
s.execute("shape.change", &json!({"preset": "roundRect"})).unwrap();
s.execute("picture.adjust", &json!({"brightness": 0.3})).unwrap();
let geom = s.doc().unwrap().selected_shapes()[0].geom.clone();
assert_ne!(geom, deckcraft_model::Geom::preset("rect"));
s.execute("picture.reset", &json!({})).unwrap();
let sh = s.doc().unwrap().selected_shapes()[0].clone();
assert_eq!(sh.geom, geom, "the frame shape stays");
match &sh.kind {
deckcraft_model::ShapeKind::Picture { fill } => assert_eq!(fill.adjust, Default::default()),
_ => panic!("not a picture"),
}
let doc = command_specs().iter().find(|c| c.id == "picture.reset").unwrap().params;
assert!(doc.contains("frame shape"), "{doc}");
}
/// HH12: exporting the same slide twice to one PNG path after an edit overwrites the file with
/// the new picture (not a bug: the second file differs).
#[test]
fn exporting_a_slide_again_overwrites_the_png_with_the_edit() {
/// Removes the folder when the test ends, a failed assertion included.
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// A folder of this run's own: created exclusively (never one an earlier or parallel run left).
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let dir = (0..100)
.map(|k| std::env::temp_dir().join(format!("deckcraft-hh12-{}-{stamp}-{k}", std::process::id())))
.find(|d| std::fs::create_dir(d).is_ok())
.expect("a fresh temporary folder");
let _cleanup = Cleanup(dir.clone());
let png = dir.join("slide.png");
let mut s = session();
let path = png.to_string_lossy().to_string();
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
let first = std::fs::read(&png).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 300]})).unwrap();
s.execute("shape.fill", &json!({"color": "#FF0000"})).unwrap();
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
let second = std::fs::read(&png).unwrap();
assert_ne!(first, second, "the second export replaced the file with the edited slide");
}
#[test]
fn action_button_fill_follows_the_theme() {
let mut s = session();
let id = s.execute("insert.actionButton", &json!({"kind": "next"})).unwrap()["id"].clone();
let fill = |s: &mut Session| {
let r = s.execute("slide.inspect", &json!({})).unwrap();
r["shapes"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["fill"].clone()
};
let before = fill(&mut s);
assert!(before.as_str().is_some_and(|f| f.starts_with('#')), "{before}");
s.execute("design.theme", &json!({"name": "Ember"})).unwrap();
let after = fill(&mut s);
assert_ne!(before, after, "the button takes the new theme's accent1");
let st = s.doc().unwrap();
let sh = st.doc.slides[0].shapes.iter().find(|x| x.fill.is_none() && x.style.is_some()).unwrap();
assert!(sh.fill.is_none(), "no explicit fill: the style binds it to accent1");
}
#[test]
fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("deckcraft-badslide-{}", std::process::id()));
let path = dir.join("a.png");
for slide in [json!(-1), json!(1.5), json!("two")] {
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "slide": slide, "scale": 0.1}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "export slide {slide} gave {r:?}");
assert!(!path.exists(), "export wrote a file for {slide}");
let r = s.execute("file.render", &json!({"slide": slide, "scale": 0.1}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "render slide {slide} gave {r:?}");
let r = s.execute("slide.colorAt", &json!({"slide": slide, "x": 1, "y": 1, "scale": 0.1}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "colorAt slide {slide} gave {r:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() {
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": "ARCH"})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("format.highlight", &json!({"color": "yellow"})).unwrap();
let has_highlight = |s: &mut Session| {
let t = s.doc().unwrap().shape(ShapeId(title)).unwrap().text.clone().unwrap();
t.paragraphs.iter().flat_map(|p| p.runs.iter()).any(|r| r.props.highlight.is_some())
};
assert!(has_highlight(&mut s));
let r = s.execute("format.highlight", &json!({"color": "notacolour"}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{r:?}");
assert!(has_highlight(&mut s), "an invalid colour must not clear the highlight");
s.execute("format.highlight", &json!({})).unwrap();
assert!(!has_highlight(&mut s), "an absent colour clears the highlight");
}
#[test]
fn master_layout_placeholder_format_inherits() {
let mut s = Session::new();
sample::open_sample(&mut s).unwrap();
let title_of = |shapes: &[model::Shape]| shapes.iter().find(|sh| sh.ph.as_ref().is_some_and(|p| p.kind.is_title())).map(|sh| sh.id.0).unwrap();
let existing = title_of(&s.doc().unwrap().doc.slides[0].shapes);
s.execute("view.slideMaster", &json!({"master": 0, "layout": 0})).unwrap();
let layout_title = title_of(&s.doc().unwrap().doc.masters[0].layouts[0].shapes);
s.execute("edit.select", &json!({"ids": [layout_title]})).unwrap();
s.execute("format.font", &json!({"family": "Montserrat"})).unwrap();
s.execute("format.align", &json!({"align": "right"})).unwrap();
let st = s.execute("format.state", &json!({})).unwrap();
assert_eq!(st["fontRef"], "Montserrat", "layout placeholder: {st}");
s.execute("view.closeMaster", &json!({})).unwrap();
s.execute("slide.new", &json!({"layout": "title"})).unwrap();
let slide = s.doc().unwrap().selection.slide;
let fresh = title_of(&s.doc().unwrap().doc.slides[slide].shapes);
for (index, id) in [(slide, fresh), (0, existing)] {
s.execute("slide.go", &json!({"index": index})).unwrap();
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
let st = s.execute("format.state", &json!({})).unwrap();
assert_eq!(st["fontRef"], "Montserrat", "slide placeholder {id}: {st}");
assert_eq!(st["align"], "r", "slide placeholder {id}: {st}");
}
}
#[test]
fn master_body_placeholder_format_keeps_per_level_formatting() {
let mut s = Session::new();
sample::open_sample(&mut s).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
let body_idx =
s.doc().unwrap().doc.masters[0].shapes.iter().position(|sh| sh.ph.as_ref().is_some_and(|p| p.kind == model::PhType::Body)).unwrap();
let id = {
let st = s.doc_mut().unwrap();
let sh = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).masters[0]).shapes[body_idx];
let mut body = model::TextBody::from_text("Alpha\nBeta");
body.paragraphs[1].level = 1;
for (l, size) in [(0u8, 32.0), (1, 28.0)] {
let mut lvl = model::text::LevelStyle::default();
lvl.run.size = Some(size);
body.list_style.set(l, lvl);
}
sh.text = Some(body);
sh.id
};
let level =
|s: &Session, l: u8| s.doc().unwrap().doc.masters[0].shapes[body_idx].text.as_ref().unwrap().list_style.level(l).cloned().unwrap_or_default();
// Bolding the level-2 paragraph reaches level 2 only.
s.doc_mut().unwrap().selection.shapes = vec![id];
s.doc_mut().unwrap().selection.text = Some(TextSel { shape: id, anchor: (1, 0), caret: (1, 4), cell: None, notes: false, line_end: None });
s.execute("format.bold", &json!({"on": true})).unwrap();
assert_eq!(level(&s, 1).run.bold, Some(true));
for l in [0, 2, 3, 8] {
assert_eq!(level(&s, l).run.bold, None, "level {}", l + 1);
}
// Grow and shrink step each level from its own size.
s.doc_mut().unwrap().selection.text = None;
// The text itself: a run's explicit size, else its level's effective size.
let shown = |s: &Session| -> Vec<Option<f64>> {
let st = s.doc().unwrap();
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
let sh = &st.doc.masters[0].shapes[body_idx];
let body = sh.text.as_ref().unwrap();
body.paragraphs.iter().map(|p| p.runs[0].props.size.or(deckcraft_model::resolve::level_style(&ctx, sh, p.level).run.size)).collect()
};
s.execute("format.grow", &json!({})).unwrap();
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(36.0), Some(32.0)));
assert_eq!(shown(&s), vec![Some(36.0), Some(32.0)]);
s.execute("format.shrink", &json!({})).unwrap();
s.execute("format.shrink", &json!({})).unwrap();
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
}
+1 -1
View File
@@ -206,7 +206,7 @@ impl Session {
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
let (start, frame) = (f.local(p), f.affine);
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
if st.selection.text.as_ref().is_none_or(|t| t.shape != id) {
self.select(|_, sel| {
sel.shapes = vec![id];
sel.text = None;
+2 -3
View File
@@ -455,12 +455,11 @@ impl Session {
self.execute("guide.move", &json!({"index": index, "pos": pos}))?
};
}
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. })
// Plain click on text: caret at the click (in a table, in the clicked cell).
if self.edit_text_at(id, at)? {
if self.edit_text_at(id, at)? => {
out = json!({"editing": id});
}
}
Some(Drag::Marquee { start, add }) => {
let r = Rect::from_points(start, p);
self.tool.marquee = None;
+75 -7
View File
@@ -393,6 +393,22 @@ fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
}
/// The Windows family and style a variable font's named instance answers to. Windows lists each
/// instance other than Regular, Italic, Bold and Bold Italic as a family of its own, as the
/// family's static files name themselves: "Montserrat ExtraBold" / "Regular" for the ExtraBold
/// instance, "Montserrat ExtraBold" / "Italic" for ExtraBold Italic.
fn instance_legacy_names(family: &str, style: &str) -> Option<(String, String)> {
if style.contains('{') {
return None;
}
let weight: Vec<&str> = style.split_whitespace().filter(|w| !matches!(norm(w).as_str(), "italic" | "oblique")).collect();
let weight = weight.join(" ");
if matches!(norm(&weight).as_str(), "" | "regular" | "bold") {
return None;
}
Some((format!("{family} {weight}"), if style_italic(style) { "Italic" } else { "Regular" }.into()))
}
/// The tables skrifa reads glyph outlines from: `glyf` (TrueType), `CFF `, `CFF2` and `VARC`. For a
/// face with none of them (bitmaps only, or outlines in Apple's `hvgl` table), [`FontDb::outline`]
/// returns an empty path for every glyph.
@@ -604,21 +620,55 @@ fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path:
/// axis settings.
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
/// Maximum number of faces we enumerate from a single font collection.
const MAX_COLLECTION_FACES: u32 = 256;
/// Maximum collection entries inspected while looking for distinct table directories.
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
let mut indices = Vec::with_capacity(MAX_COLLECTION_FACES as usize);
for (index, offset) in entries.into_iter().take(MAX_COLLECTION_ENTRIES_SCANNED as usize) {
if offsets.insert(offset) {
indices.push(index);
if indices.len() == MAX_COLLECTION_FACES as usize {
break;
}
}
}
indices
}
fn capped_named_instance_count(count: usize) -> usize {
count.min(MAX_NAMED_INSTANCES_PER_FACE)
}
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
/// named instance (InDesign lists them as styles).
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => 0,
let indices = match FileRef::new(data) {
Ok(FileRef::Font(_)) => vec![0],
Ok(FileRef::Collection(c)) => {
let entries = (0..c.len().min(MAX_COLLECTION_ENTRIES_SCANNED)).filter_map(|i| {
let start = 12usize.checked_add((i as usize).checked_mul(4)?)?;
let end = start.checked_add(4)?;
let offset = u32::from_be_bytes(data.get(start..end)?.try_into().ok()?);
Some((i, offset))
});
distinct_face_indices(entries)
}
Err(_) => Vec::new(),
};
let mut out = Vec::new();
for i in 0..count {
for i in indices {
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
for ni in f.named_instances().iter() {
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
continue;
@@ -686,7 +736,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
.map(|v| v >= 0.5)
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { instance_legacy_names(&family, &style) };
Some(FontFace {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
@@ -1435,6 +1485,24 @@ mod font_dir_tests {
use super::*;
#[test]
fn duplicate_collection_offsets_do_not_consume_face_cap() {
let mut entries = vec![(0, 7); MAX_COLLECTION_FACES as usize];
entries.extend((0..MAX_COLLECTION_FACES).map(|i| (i + MAX_COLLECTION_FACES, i + 8)));
let faces = distinct_face_indices(entries);
assert_eq!(faces.len(), MAX_COLLECTION_FACES as usize);
assert_eq!(faces.first(), Some(&0));
assert_eq!(faces.get(1), Some(&MAX_COLLECTION_FACES));
assert_eq!(faces.last(), Some(&(MAX_COLLECTION_FACES * 2 - 2)));
}
#[test]
fn named_instance_limit_is_bounded_per_face() {
assert_eq!(capped_named_instance_count(0), 0);
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE), MAX_NAMED_INSTANCES_PER_FACE);
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+30
View File
@@ -147,6 +147,36 @@ fn windows_family_names_find_their_faces() {
}
}
#[test]
fn variable_font_instances_answer_to_their_windows_names() {
// Windows lists a variable font's named instances other than Regular, Italic, Bold and
// Bold Italic as families of their own, and documents name them that way ("Montserrat
// ExtraBold"). The bundled Montserrat is variable, so these names found no face and drew
// in Inter Regular, even with the family's static files installed (their faces are
// skipped as already present).
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
if !db.is_loaded("Montserrat") {
return; // built without craft-fonts
}
for (name, style, weight, italic) in [
("Montserrat ExtraBold", "Regular", 800.0, false),
("Montserrat SemiBold", "Regular", 600.0, false),
("Montserrat Medium", "Regular", 500.0, false),
("Montserrat Medium", "Italic", 500.0, true),
("Montserrat Thin", "Bold", 100.0, false),
] {
assert!(db.has_family(name), "{name}");
let f = db.face(name, style);
assert_eq!((f.family.as_str(), f.weight, f.italic), ("Montserrat", weight, italic), "{name} {style}");
assert_eq!(db.document_face(name, style).family, "Montserrat", "{name}: installed, so not Calibri");
}
// Regular and Bold are the family itself, not families of their own.
assert!(!db.has_family("Montserrat Regular"));
assert!(!db.has_family("Montserrat Bold"));
assert_eq!(db.face("Montserrat", "Bold").weight, 700.0);
}
#[test]
fn faces_without_outlines_are_not_cataloged() {
// Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in
+46 -34
View File
@@ -216,26 +216,37 @@ impl Decoder {
pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> {
ensure!(avcc.len() >= 7, "avcC too short");
ensure!(avcc[0] == 1, "unsupported avcC version {}", avcc[0]);
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
let mut pos = 5;
let read_sets = |pos: &mut usize, count: usize, d: &mut Decoder| -> Result<()> {
let mut sets = Vec::new();
let read_sets = |pos: &mut usize, count: usize, sets: &mut Vec<(usize, usize)>| -> Result<()> {
for _ in 0..count {
ensure!(*pos + 2 <= avcc.len(), "avcC truncated");
let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize;
*pos += 2;
ensure!(*pos + len <= avcc.len(), "avcC truncated");
d.handle_nal(&avcc[*pos..*pos + len], 0)?;
sets.push((*pos, len));
*pos += len;
}
Ok(())
};
let nsps = (avcc[pos] & 0x1f) as usize;
pos += 1;
read_sets(&mut pos, nsps, self)?;
read_sets(&mut pos, nsps, &mut sets)?;
ensure!(pos < avcc.len(), "avcC truncated");
let npps = avcc[pos] as usize;
pos += 1;
read_sets(&mut pos, npps, self)
read_sets(&mut pos, npps, &mut sets)?;
for (i, (start, len)) in sets.iter().enumerate() {
let nal = &avcc[*start..*start + *len];
let header = nal.first().copied().ok_or_else(|| Error::Invalid("empty avcC parameter set".into()))?;
let expected = if i < nsps { nal_type::SPS } else { nal_type::PPS };
ensure!(header & 0x1f == expected, "avcC parameter set has NAL type {}, expected {expected}", header & 0x1f);
}
for (start, len) in sets {
self.handle_nal(&avcc[start..start + len], 0)?;
}
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
Ok(())
}
/// Length of NAL length prefixes (from avcC), or None for Annex-B input.
@@ -557,16 +568,16 @@ impl Decoder {
/// Decode all slices of one picture and publish its rows.
fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool) {
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
let mut pic = match reuse {
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
p.reset(frame.clone());
p
}
_ => PicState::new(frame.clone()),
};
pic.skip_deblock = draft;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
let mut pic = match reuse {
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
p.reset(frame.clone());
p
}
_ => PicState::new(frame.clone()),
};
pic.skip_deblock = draft;
let mut first_err = None;
for s in &slices {
if let Err(e) = decode_slice(s, &mut pic) {
@@ -574,10 +585,10 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
}
}
pic.flush_rows(true);
first_err
(pic, first_err)
}));
let err = match result {
Ok(e) => e,
let (pic, err) = match result {
Ok((pic, err)) => (Some(pic), err),
Err(_) => {
// Make sure readers never block on this frame.
let fallback = Planes::gray(frame.width, frame.height);
@@ -587,34 +598,35 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
frame.publish(r, Frame::make_row(&fallback, r, &blank, &|_, _, _| u32::MAX));
}
}
pic = PicState::new(frame.clone());
Some(Error::Invalid("internal error while decoding a picture".into()))
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
}
};
if let Ok(mut s) = shared.stats.lock() {
s.pictures += 1;
s.draft_pictures += draft as u64;
for st in &pic.mbs {
match st.kind {
MbKind::I4x4 => s.mb_i4x4 += 1,
MbKind::I8x8 => s.mb_i8x8 += 1,
MbKind::I16x16 => s.mb_i16x16 += 1,
MbKind::IPcm => s.mb_pcm += 1,
MbKind::PSkip => s.mb_p_skip += 1,
MbKind::BSkip => s.mb_b_skip += 1,
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
MbKind::Inter => s.mb_inter += 1,
MbKind::None => {}
}
if st.transform_8x8 && !st.kind.is_intra() {
s.mb_inter_8x8_transform += 1;
if let Some(pic) = &pic {
for st in &pic.mbs {
match st.kind {
MbKind::I4x4 => s.mb_i4x4 += 1,
MbKind::I8x8 => s.mb_i8x8 += 1,
MbKind::I16x16 => s.mb_i16x16 += 1,
MbKind::IPcm => s.mb_pcm += 1,
MbKind::PSkip => s.mb_p_skip += 1,
MbKind::BSkip => s.mb_b_skip += 1,
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
MbKind::Inter => s.mb_inter += 1,
MbKind::None => {}
}
if st.transform_8x8 && !st.kind.is_intra() {
s.mb_inter_8x8_transform += 1;
}
}
}
}
if let (Some(e), Ok(mut slot)) = (err, shared.error.lock()) {
slot.get_or_insert(e);
}
if let Ok(mut p) = shared.pool.lock()
if let (Some(pic), Ok(mut p)) = (pic, shared.pool.lock())
&& p.len() < 4
{
p.push(pic);
+50
View File
@@ -82,6 +82,56 @@ fn pps(cabac: bool, weighted: bool) -> Vec<u8> {
nal(3, 8, &w.finish())
}
#[test]
fn avcc_accepts_generated_sps_and_pps() {
let sps = sps(66, 4, false, PocType::T2);
let pps = pps(false, false);
let (sps, pps) = (&sps[4..], &pps[4..]);
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
avcc.extend_from_slice(&(sps.len() as u16).to_be_bytes());
avcc.extend_from_slice(sps);
avcc.push(1);
avcc.extend_from_slice(&(pps.len() as u16).to_be_bytes());
avcc.extend_from_slice(pps);
assert!(Decoder::from_avcc(&avcc).is_ok());
}
#[test]
fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
let sequence = sps(66, 4, false, PocType::T0);
let picture = pps(false, false);
let slice = slice_nal(
&Pic {
idr: true,
frame_num: 0,
ref_idc: 3,
long_term_ref: false,
mods: vec![],
mmcos: vec![],
mbs: vec![Some(vec![128; 384]); NMB],
b: false,
poc_lsb: Some(0),
poc_delta: None,
wt: None,
},
false,
);
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
let nal = &sequence[4..];
avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
avcc.extend_from_slice(nal);
avcc.push(2);
avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes());
avcc.extend_from_slice(&picture[4..]);
avcc.extend_from_slice(&((slice.len() - 4) as u16).to_be_bytes());
avcc.extend_from_slice(&slice[4..]);
let mut decoder = Decoder::with_threads(1);
assert!(decoder.configure_avcc(&avcc).is_err());
assert!(decoder.flush().is_empty());
assert_eq!(decoder.stats().pictures, 0);
}
#[derive(Default, Clone)]
struct Pic {
idr: bool,
+58 -26
View File
@@ -160,22 +160,35 @@ impl Decoder {
pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> {
ensure!(hvcc.len() >= 23, "hvcC too short");
ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]);
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
let num_arrays = hvcc[22] as usize;
let mut pos = 23;
for _ in 0..num_arrays {
ensure!(pos + 3 <= hvcc.len(), "hvcC truncated");
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
pos += 3;
for _ in 0..n {
ensure!(pos + 2 <= hvcc.len(), "hvcC truncated");
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
pos += 2;
ensure!(pos + len <= hvcc.len(), "hvcC truncated");
self.handle_nal(&hvcc[pos..pos + len], 0)?;
pos += len;
for pass in 0..2 {
let mut pos = 23;
for _ in 0..num_arrays {
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 3, "hvcC truncated");
let array_type = hvcc[pos] & 0x3f;
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
pos += 3;
for _ in 0..n {
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 2, "hvcC truncated");
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
pos += 2;
ensure!(len <= hvcc.len() - pos, "hvcC truncated");
let nal = &hvcc[pos..pos + len];
if pass == 0 {
let header = NalHeader::parse(nal)?;
ensure!(
[nal_type::VPS, nal_type::SPS, nal_type::PPS, nal_type::PREFIX_SEI, nal_type::SUFFIX_SEI].contains(&array_type),
"hvcC array has unsupported NAL type {array_type}"
);
ensure!(header.nal_type == array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type);
} else {
self.handle_nal(nal, 0)?;
}
pos += len;
}
}
}
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
Ok(())
}
@@ -561,9 +574,9 @@ fn run_job(
draft: bool,
shared: &Shared,
) {
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
pic.draft = draft;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
pic.draft = draft;
let mut first_err = None;
for s in &slices {
if let Err(e) = SliceDecoder::decode(&mut pic, s) {
@@ -571,10 +584,10 @@ fn run_job(
}
}
crate::filter::finish(&mut pic);
first_err
(pic, first_err)
}));
let err = match result {
Ok(e) => e,
let (pic, err) = match result {
Ok((pic, err)) => (Some(pic), err),
Err(_) => {
// make sure readers never block on this frame
let gray = Frame::gray(0, frame.poc, &frame);
@@ -584,18 +597,20 @@ fn run_job(
frame.publish(r, crate::picture::FrameRow { y: row.y.clone(), cb: row.cb.clone(), cr: row.cr.clone(), col: row.col.clone() });
}
}
Some(Error::Invalid("internal error while decoding a picture".into()))
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
}
};
if let Ok(mut s) = shared.stats.lock() {
s.pictures += 1;
for b in &pic.blk {
if b.flags & F_INTRA != 0 {
s.intra_4x4_blocks += 1;
} else if b.flags & F_SKIP != 0 {
s.skip_4x4_blocks += 1;
} else if b.flags != 0 {
s.inter_4x4_blocks += 1;
if let Some(pic) = &pic {
for b in &pic.blk {
if b.flags & F_INTRA != 0 {
s.intra_4x4_blocks += 1;
} else if b.flags & F_SKIP != 0 {
s.skip_4x4_blocks += 1;
} else if b.flags != 0 {
s.inter_4x4_blocks += 1;
}
}
}
}
@@ -651,3 +666,20 @@ fn make_picture(o: &Output) -> Picture {
draft: o.meta.draft,
}
}
#[cfg(test)]
mod config_tests {
use super::Decoder;
#[test]
fn hvcc_skips_prefix_and_suffix_sei_arrays() {
let mut config = vec![0; 23];
config[0] = 1;
config[22] = 2;
config.extend_from_slice(&[39, 0, 1, 0, 2, 0x4e, 0x01]);
config.extend_from_slice(&[40, 0, 1, 0, 2, 0x50, 0x01]);
let decoder = Decoder::from_hvcc(&config);
assert!(decoder.is_ok());
}
}
+59
View File
@@ -644,3 +644,62 @@ fn picture_store_is_checked_against_the_memory_budget() {
assert!(!decodes_within(&stream, pictures * one - 1));
assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET));
}
#[test]
fn hvcc_accepts_generated_vps_sps_and_pps() {
let sets = [vps(), sps(true), pps(0, &None, false)];
let mut hvcc = vec![0; 23];
hvcc[0] = 1;
hvcc[22] = sets.len() as u8;
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
let nal = &set[4..];
hvcc.extend_from_slice(&[kind, 0, 1]);
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
hvcc.extend_from_slice(nal);
}
assert!(Decoder::from_hvcc(&hvcc).is_ok());
}
#[test]
fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
let sets = [vps(), sps(true), pps(0, &None, false)];
let mut stream = Vec::new();
let mut saved = None;
encode_picture(
&PicDesc {
nal_type: 19,
poc: 0,
intra: true,
cu: Cu::Pcm,
pps_id: 0,
st: vec![],
lt: vec![],
num_ref: 0,
list_entry: None,
segments: whole(WC * HC),
},
&None,
&content(0),
&mut stream,
&mut saved,
);
let mut hvcc = vec![0; 23];
hvcc[0] = 1;
hvcc[22] = 4;
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
let nal = &set[4..];
hvcc.extend_from_slice(&[kind, 0, 1]);
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
hvcc.extend_from_slice(nal);
}
let slice = &stream[4..];
hvcc.extend_from_slice(&[19, 0, 1]);
hvcc.extend_from_slice(&(slice.len() as u16).to_be_bytes());
hvcc.extend_from_slice(slice);
let mut decoder = Decoder::with_threads(1);
assert!(decoder.configure_hvcc(&hvcc).is_err());
assert!(decoder.flush().is_empty());
assert_eq!(decoder.stats().pictures, 0);
}
+48 -4
View File
@@ -1,6 +1,6 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use std::io::{self, BufRead, Write};
use serde_json::{Value, json};
@@ -17,6 +17,8 @@ const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Keep request frames in step with the connected backend's reply limit.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone). Coordinates are points on the \
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
@@ -62,9 +64,16 @@ impl Server {
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
let mut input = input;
while let Some(frame) = read_frame(&mut input)? {
let reply = match frame {
Frame::Complete(bytes) => match std::str::from_utf8(&bytes) {
Ok(line) => self.handle_line(line),
Err(_) => Some(error(Value::Null, PARSE_ERROR, "request frame is not valid UTF-8").to_string()),
},
Frame::TooLarge => Some(error(Value::Null, INVALID_REQUEST, "request frame exceeds 16 MiB").to_string()),
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
@@ -172,3 +181,38 @@ impl Server {
}
}
}
enum Frame {
Complete(Vec<u8>),
TooLarge,
}
/// Read through one newline while retaining no more than the documented frame budget.
fn read_frame(input: &mut impl BufRead) -> io::Result<Option<Frame>> {
let mut bytes = Vec::new();
let mut too_large = false;
loop {
let available = input.fill_buf()?;
if available.is_empty() {
if bytes.is_empty() && !too_large {
return Ok(None);
}
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
}
let count = available.iter().position(|byte| *byte == b'\n').map_or(available.len(), |i| i + 1);
if !too_large {
let remaining = MAX_FRAME_BYTES.saturating_sub(bytes.len());
if count > remaining {
too_large = true;
bytes.clear();
} else {
bytes.extend_from_slice(&available[..count]);
}
}
let ended = available[count - 1] == b'\n';
input.consume(count);
if ended {
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
}
}
}
+62
View File
@@ -10,6 +10,34 @@ fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
serde_json::from_str(&out).expect("json")
}
#[test]
fn oversized_stdio_frame_is_drained_before_the_next_request() {
let mut s = Server::new(Box::new(Headless::new()));
let mut input = vec![b'x'; 16 * 1024 * 1024 + 1];
input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n");
let mut output = Vec::new();
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 2);
assert_eq!(replies[0]["error"]["code"], -32600);
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("16 MiB"));
assert_eq!(replies[1]["id"], 1);
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
}
#[test]
fn invalid_utf8_stdio_frame_gets_a_parse_error_and_does_not_stop_serving() {
let mut s = Server::new(Box::new(Headless::new()));
let mut input = b"\xff\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec();
let mut output = Vec::new();
s.serve(std::io::Cursor::new(&mut input), &mut output).unwrap();
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 2);
assert_eq!(replies[0]["error"]["code"], -32700);
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("UTF-8"));
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
}
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
let res = &r["result"];
@@ -34,6 +62,40 @@ fn handshake_and_tool_list() {
assert!(s.handle_line("{}").is_some());
}
/// #1151: `insert_chart` takes x, y, w and h like `insert_picture` and `insert_table`.
#[test]
fn insert_chart_takes_a_box() {
let mut s = Server::new(Box::new(Headless::new()));
tool(&mut s, "new_presentation", json!({"theme": "Harbor"}));
let chart = json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]});
let box_of = |s: &mut Server, id: u64| {
let sl = tool(s, "inspect_slide", json!({}));
let shape = sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap().clone();
assert_eq!(shape["kind"], "chart");
shape["box"].clone()
};
let mut args = chart.clone();
for (k, v) in [("x", 50), ("y", 60), ("w", 300), ("h", 200)] {
args[k] = json!(v);
}
let placed = tool(&mut s, "insert_chart", args)["id"].as_u64().unwrap();
assert_eq!(box_of(&mut s, placed), json!([50.0, 60.0, 300.0, 200.0]));
// without a full box the chart keeps its default place, as for pictures and tables
let plain = tool(&mut s, "insert_chart", chart.clone())["id"].as_u64().unwrap();
let default_box = box_of(&mut s, plain);
let mut partial = chart.clone();
partial["x"] = json!(50);
let partial_id = tool(&mut s, "insert_chart", partial)["id"].as_u64().unwrap();
assert_eq!(box_of(&mut s, partial_id), default_box);
// a hostile box is answered, not a panic
let mut odd = chart;
for (k, v) in [("x", json!(-1e300)), ("y", json!(1e300)), ("w", json!(-5)), ("h", json!(0))] {
odd[k] = v;
}
let r = call(&mut s, 11, "tools/call", json!({"name": "insert_chart", "arguments": odd}));
assert!(r["result"].is_object(), "{r}");
}
#[test]
fn agent_builds_a_deck() {
let mut s = Server::new(Box::new(Headless::new()));
+9 -3
View File
@@ -234,9 +234,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"insert_chart",
"Insert chart",
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series.",
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series, optionally at a box.",
obj(
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}}),
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}, "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}),
&[],
),
false,
@@ -444,7 +444,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
}
wrap(exec(b, "insert.table", p))
}
"insert_chart" => wrap(exec(b, "insert.chart", a)),
"insert_chart" => {
let mut p = a.clone();
if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) {
p["rect"] = json!([x, y, w, h]);
}
wrap(exec(b, "insert.chart", p))
}
"select" => wrap(exec(b, "edit.select", a)),
"pointer" => wrap(b.call("ui.pointer", a)),
"select_tool" => wrap(b.call("ui.tool.select", a)),
+4 -2
View File
@@ -31,7 +31,9 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
let read = read.then(|| in_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
let write = write.then(|| out_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
let roots = AutomationRoots::new(&read, &write).unwrap();
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
ok(&mut server, "new_presentation", json!({}));
(server, base)
@@ -215,7 +217,7 @@ fn without_roots_nothing_is_confined() {
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("a.txt"), "Anywhere").unwrap();
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap())));
ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))}));
ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))}));
assert!(base.join("b.deckcraft").exists());
+20 -7
View File
@@ -50,6 +50,8 @@ const MAX_CHARS: usize = 32_768;
const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
/// Nesting depth of region nodes.
const MAX_REGION_DEPTH: usize = 32;
/// Region nodes and path points parsed together, including nodes in sibling branches.
const MAX_REGION_WORK: usize = MAX_POINTS;
const TA_RIGHT: u32 = 2;
const TA_CENTER: u32 = 6;
@@ -93,7 +95,7 @@ impl Fill {
if w == 0.0 || h == 0.0 {
return [0, 0, 0, 0];
}
if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) {
if *wrap == 4 && !((0.0..w).contains(&p.0) && (0.0..h).contains(&p.1)) {
return [0, 0, 0, 0];
}
let x = (p.0.rem_euclid(w) as usize).min(img.w - 1);
@@ -1106,12 +1108,14 @@ fn path(r: Bytes) -> Option<Vec<Op>> {
/// A region object as a convex area in world units (non-convex parts become their bounding
/// rectangle).
fn region(r: Bytes) -> Option<Obj> {
region_node(r, 8, 0).map(|(a, _)| Obj::Region(a))
let mut work = 0;
region_node(r, 8, 0, &mut work).map(|(a, _)| Obj::Region(a))
}
/// One region node at `off`: its area and its length in bytes.
fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> {
if depth > MAX_REGION_DEPTH {
fn region_node(r: Bytes, off: usize, depth: usize, work: &mut usize) -> Option<(Area, usize)> {
*work = work.checked_add(1)?;
if depth > MAX_REGION_DEPTH || *work > MAX_REGION_WORK {
return None;
}
match r.u32(off)? {
@@ -1121,14 +1125,23 @@ fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> {
}
0x1000_0001 => {
let size = r.u32(off + 4)? as usize;
let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default();
if size < 12 {
return None;
}
let data = Bytes(r.slice(off + 8, size)?);
let points = (data.u32(4)? as usize).min(MAX_POINTS);
*work = work.checked_add(points)?;
if *work > MAX_REGION_WORK {
return None;
}
let area = area_of_ops(&path(data)?);
Some((Some(area), 8usize.checked_add(size)?))
}
0x1000_0002 => Some((Some(Vec::new()), 4)),
0x1000_0003 => Some((None, 4)),
mode @ 0..=5 => {
let (a, la) = region_node(r, off + 4, depth + 1)?;
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?;
let (a, la) = region_node(r, off + 4, depth + 1, work)?;
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1, work)?;
let out = match mode {
0 => b,
1 => area_intersect(a, b),
+28
View File
@@ -1875,6 +1875,34 @@ fn emfplus_region_clip_intersects_rectangles() {
assert_eq!((p.width, p.height), (20.0, 20.0));
}
#[test]
fn emfplus_region_path_cannot_read_into_its_sibling() {
let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]);
// The path declares only its 12-byte header, but claims three points. The following
// rectangle must not supply those points or be parsed twice as the next node.
let path = cat(vec![h32(0x1000_0001), h32(12), h32(0), h32(3), h32(0)]);
let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), path, rect([10.0, 10.0, 20.0, 20.0])]);
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, &region), prec(0x4034, 6, &[]), fill]);
assert_eq!(p.items.len(), 1);
assert_eq!((p.width, p.height), (100.0, 100.0));
}
#[test]
fn emfplus_region_path_keeps_its_shape() {
let mut path_data = cat(vec![h32(0), h32(4), h32(0x4000)]);
for (x, y) in [(10i16, 10i16), (30, 10), (30, 30), (10, 30)] {
path_data.extend(x.to_le_bytes());
path_data.extend(y.to_le_bytes());
}
path_data.extend([0, 1, 1, 0x81]);
let path_node = cat(vec![h32(0x1000_0001), h32(path_data.len() as u32), path_data]);
let region = cat(vec![h32(0xDBC0_1002), h32(1), path_node]);
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, &region), prec(0x4034, 6, &[]), fill]);
assert_eq!((p.width, p.height), (20.0, 20.0));
}
#[test]
fn emfplus_pie_and_arc() {
// A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100).
+16 -16
View File
@@ -367,37 +367,37 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]),
("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]),
("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]),
("grow", "Grow & Turn", AnimClass::Entrance, 31, 500, &[]),
("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]),
("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]),
("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]),
("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]),
("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]),
("box", "Box", AnimClass::Entrance, 4, 500, &["in", "out"]),
("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]),
("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]),
("circle", "Circle", AnimClass::Entrance, 6, 500, &["in", "out"]),
("diamond", "Diamond", AnimClass::Entrance, 8, 500, &["in", "out"]),
("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]),
("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]),
("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]),
("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]),
("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]),
("expand", "Expand", AnimClass::Entrance, 55, 500, &[]),
("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]),
("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),
("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),
("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]),
("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]),
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]),
("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),
("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]),
("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]),
("desaturate", "Desaturate", AnimClass::Emphasis, 35, 500, &[]),
("darken", "Darken", AnimClass::Emphasis, 25, 500, &[]),
("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]),
("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]),
("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]),
("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]),
("objectColor", "Object Color", AnimClass::Emphasis, 19, 2000, &[]),
("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]),
("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]),
("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]),
("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]),
("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]),
("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]),
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 500, &[]),
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]),
("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]),
("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]),
("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]),
@@ -408,20 +408,20 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]),
("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]),
("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]),
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 500, &[]),
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]),
("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]),
("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]),
("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]),
("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]),
("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]),
("boxOut", "Box", AnimClass::Exit, 4, 500, &["in", "out"]),
("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]),
("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]),
("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["in", "out"]),
("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]),
("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]),
("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]),
("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]),
("spinnerOut", "Spinner", AnimClass::Exit, 49, 1000, &[]),
("contract", "Contract", AnimClass::Exit, 55, 500, &[]),
("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]),
("contract", "Contract", AnimClass::Exit, 55, 1000, &[]),
("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]),
("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]),
("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]),
+18 -1
View File
@@ -1270,7 +1270,14 @@ impl Chart {
let mut bins: Vec<Bin> = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect();
for x in v {
// (lo, hi] bins, the first one also holding the minimum.
let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
let mut i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
// The division can round across an edge: settle on the bin whose stored bounds hold x.
while i > 0 && x <= bins[i - 1].hi {
i -= 1;
}
while i + 1 < count && x > bins[i].hi {
i += 1;
}
if let Some(b) = bins.get_mut(i) {
b.count += 1;
}
@@ -1535,6 +1542,16 @@ mod chartex_tests {
assert_eq!(c.histogram_bins().len(), 7);
}
#[test]
fn histogram_value_on_a_bin_edge_counts_in_that_bin() {
let mut c = obs(&[0.0, 2.1, 3.0]);
c.bin_size = Some(0.3);
let bins = c.histogram_bins();
let own = bins.iter().find(|b| b.lo < 2.1 && 2.1 <= b.hi).expect("a bin holds 2.1");
assert_eq!(own.count, 1, "2.1 must be counted in ({}, {}]", own.lo, own.hi);
assert_eq!(bins.iter().map(|b| b.count).sum::<usize>(), 3);
}
#[test]
fn pareto_view_sorts_bins_and_adds_the_cumulative_line() {
let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]);
+18
View File
@@ -418,6 +418,8 @@ fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
/// that shares nothing with the old text replaces it as plain text.
pub fn replace_text(body: &mut TextBody, text: &str) {
// Normalise line endings first so the common prefix/suffix cannot split a `\r\n` pair.
let text = &text.replace("\r\n", "\n").replace('\r', "\n");
let old: Vec<char> = body.text().chars().collect();
let new: Vec<char> = text.chars().collect();
if old == new {
@@ -723,6 +725,22 @@ mod tests {
b
}
#[test]
fn replace_text_crlf_matches_from_text() {
// The kept suffix must not start inside a `\r\n` pair.
let mut b = TextBody::from_text("x\ny");
replace_text(&mut b, "z\r\ny");
assert_eq!(b.text(), "z\ny");
// CRLF in the kept prefix, and the same text with CRLF changes nothing.
let before = notes();
let mut b = before.clone();
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond point");
assert_eq!(b, before);
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond\rpoint");
assert_eq!(b.text(), "Overview\nFirst point\nSecond\npoint");
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn replace_text_keeps_formatting() {
let before = notes();
+61
View File
@@ -72,6 +72,36 @@ pub struct GradientStop {
pub color: ColorRef,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GradientFlip {
None,
X,
Y,
Xy,
}
impl GradientFlip {
pub fn xml(self) -> &'static str {
match self {
GradientFlip::None => "none",
GradientFlip::X => "x",
GradientFlip::Y => "y",
GradientFlip::Xy => "xy",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
match value {
"none" => Some(GradientFlip::None),
"x" => Some(GradientFlip::X),
"y" => Some(GradientFlip::Y),
"xy" => Some(GradientFlip::Xy),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GradientShape {
@@ -90,6 +120,8 @@ pub enum GradientShape {
pub struct Gradient {
pub stops: Vec<GradientStop>,
pub shape: GradientShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flip: Option<GradientFlip>,
#[serde(default = "crate::yes")]
pub rotate_with_shape: bool,
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
@@ -232,6 +264,31 @@ pub enum LineJoin {
Miter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineAlign {
#[default]
Center,
Inside,
}
impl LineAlign {
pub fn xml(self) -> &'static str {
match self {
LineAlign::Center => "ctr",
LineAlign::Inside => "in",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
match value {
"ctr" => Some(LineAlign::Center),
"in" => Some(LineAlign::Inside),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Compound {
@@ -324,6 +381,8 @@ pub struct Line {
pub dash: Option<Dash>,
pub cap: Option<LineCap>,
pub join: Option<LineJoin>,
pub miter_limit: Option<u32>,
pub align: Option<LineAlign>,
pub compound: Option<Compound>,
pub head: Option<LineEnd>,
pub tail: Option<LineEnd>,
@@ -347,6 +406,8 @@ impl Line {
}
self.cap = self.cap.or(parent.cap);
self.join = self.join.or(parent.join);
self.miter_limit = self.miter_limit.or(parent.miter_limit);
self.align = self.align.or(parent.align);
self.compound = self.compound.or(parent.compound);
if self.head.is_none() {
self.head.clone_from(&parent.head);
+7 -2
View File
@@ -73,8 +73,13 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
let p = Presentation::default();
let s = &p.slides[0];
let ctx = Ctx::for_slide(&p, s).unwrap();
let own =
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let own = style::Gradient {
tile: [0.0; 4],
stops: vec![],
shape: style::GradientShape::Linear { angle: 45.0, scaled: true },
rotate_with_shape: true,
flip: None,
};
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
st.fill_ref.0 = 3;
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
+81 -5
View File
@@ -236,10 +236,13 @@ pub struct ParaProps {
pub space_after: Option<Spacing>,
pub bullet: Option<Bullet>,
pub bullet_font: Option<String>,
pub bullet_font_follow_text: Option<bool>,
/// `None` = follow text colour.
pub bullet_color: Option<ColorRef>,
pub bullet_color_follow_text: Option<bool>,
/// Bullet size as a fraction of the text size.
pub bullet_size: Option<f64>,
pub bullet_size_follow_text: Option<bool>,
pub tabs: Option<Vec<TabStop>>,
pub default_tab: Option<f64>,
pub rtl: Option<bool>,
@@ -263,13 +266,36 @@ impl ParaProps {
if self.bullet.is_none() {
self.bullet.clone_from(&p.bullet);
}
if self.bullet_font.is_none() {
self.bullet_font.clone_from(&p.bullet_font);
if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() {
self.bullet_font_follow_text = None;
}
if self.bullet_color.is_none() {
self.bullet_color.clone_from(&p.bullet_color);
if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() {
if p.bullet_font_follow_text == Some(true) {
self.bullet_font_follow_text = Some(true);
} else {
self.bullet_font.clone_from(&p.bullet_font);
}
}
if self.bullet_color_follow_text == Some(false) || self.bullet_color.is_some() {
self.bullet_color_follow_text = None;
}
if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() {
if p.bullet_color_follow_text == Some(true) {
self.bullet_color_follow_text = Some(true);
} else {
self.bullet_color.clone_from(&p.bullet_color);
}
}
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() {
self.bullet_size_follow_text = None;
}
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() {
if p.bullet_size_follow_text == Some(true) {
self.bullet_size_follow_text = Some(true);
} else {
self.bullet_size = p.bullet_size;
}
}
self.bullet_size = self.bullet_size.or(p.bullet_size);
if self.tabs.is_none() {
self.tabs.clone_from(&p.tabs);
}
@@ -632,3 +658,53 @@ impl TextBody {
n + self.paragraphs.len().saturating_sub(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_color::Rgba;
#[test]
fn explicit_bullet_follow_text_resets_block_inheritance() {
let parent = ParaProps {
bullet_font: Some("Inter".into()),
bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))),
bullet_size: Some(2.0),
..Default::default()
};
let mut child = ParaProps {
bullet_font_follow_text: Some(true),
bullet_color_follow_text: Some(true),
bullet_size_follow_text: Some(true),
..Default::default()
};
child.inherit(&parent);
assert_eq!(child.bullet_font, None);
assert_eq!(child.bullet_color, None);
assert_eq!(child.bullet_size, None);
let mut inherited_reset = ParaProps::default();
inherited_reset.inherit(&ParaProps {
bullet_font_follow_text: Some(true),
bullet_color_follow_text: Some(true),
bullet_size_follow_text: Some(true),
..Default::default()
});
assert_eq!(inherited_reset.bullet_font_follow_text, Some(true));
assert_eq!(inherited_reset.bullet_color_follow_text, Some(true));
assert_eq!(inherited_reset.bullet_size_follow_text, Some(true));
let mut explicit_false = ParaProps {
bullet_font_follow_text: Some(false),
bullet_color_follow_text: Some(false),
bullet_size_follow_text: Some(false),
..Default::default()
};
explicit_false.inherit(&parent);
assert_eq!(explicit_false.bullet_font, parent.bullet_font);
assert_eq!(explicit_false.bullet_color, parent.bullet_color);
assert_eq!(explicit_false.bullet_size, parent.bullet_size);
}
}
+1
View File
@@ -115,6 +115,7 @@ pub fn default_format() -> FormatScheme {
],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
flip: None,
})
};
FormatScheme {
+1 -1
View File
@@ -480,7 +480,7 @@ fn num(v: f64) -> String {
if a == 0.0 {
return "0".to_string();
}
if a >= 1.0e6 || a < 1.0e-3 {
if !(1.0e-3..1.0e6).contains(&a) {
return format!("{v:.2e}").replace('+', "");
}
let digits = if a >= 100.0 {
+45 -11
View File
@@ -196,6 +196,21 @@ pub struct OggFile {
pub warnings: Vec<String>,
}
/// Source bytes damage recovery may read per file: the windows the scan for the next capture
/// pattern fills and the pages read only to find their CRC wrong.
const RECOVERY_BUDGET: u64 = 32 << 20;
/// Warnings kept per file (not counting the one about the budget); the rest are counted in one last entry.
const MAX_WARNINGS: usize = 256;
/// Record a warning, keeping at most `MAX_WARNINGS` and one entry saying the rest were dropped.
fn warn(warnings: &mut Vec<String>, msg: String) {
match warnings.len().cmp(&MAX_WARNINGS) {
std::cmp::Ordering::Less => warnings.push(msg),
std::cmp::Ordering::Equal => warnings.push("further warnings omitted".into()),
std::cmp::Ordering::Greater => {}
}
}
struct Partial {
parts: Vec<(u64, u32)>,
size: u32,
@@ -219,7 +234,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
match scanner.find(src, pos + 1, len)? {
Some(p) => {
if found_any {
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
warn(&mut warnings, format!("lost sync at byte {pos}; resumed at {p}"));
}
pos = p;
continue;
@@ -229,14 +244,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
}
let nseg = hdr[26] as usize;
if pos + 27 + nseg as u64 > len {
warnings.push(format!("truncated page header at byte {pos}"));
warn(&mut warnings, format!("truncated page header at byte {pos}"));
break;
}
src.read_at(pos + 27, &mut hdr[27..27 + nseg])?;
let body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum();
let page_len = 27 + nseg as u64 + body_len;
if pos + page_len > len {
warnings.push(format!("truncated page at byte {pos}"));
warn(&mut warnings, format!("truncated page at byte {pos}"));
break;
}
let mut page = vec![0u8; page_len as usize];
@@ -244,7 +259,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
let stored_crc = u32::from_le_bytes([page[22], page[23], page[24], page[25]]);
page[22..26].fill(0);
if crc32(&page) != stored_crc {
warnings.push(format!("bad CRC on page at byte {pos}"));
warn(&mut warnings, format!("bad CRC on page at byte {pos}"));
// a page read only to find it damaged is recovery work, charged like the scan's windows
if scanner.charge(page_len) {
break;
}
pos += 1;
continue;
}
@@ -275,7 +294,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
// a continued page without a pending packet (lost a page): drop the fragment
let continued = flags & 0x01 != 0;
if !continued && partial.remove(&serial).is_some() {
warnings.push(format!("incomplete packet before page at byte {pos}"));
warn(&mut warnings, format!("incomplete packet before page at byte {pos}"));
}
let mut completed: Vec<Packet> = Vec::new();
let mut off = 0u64;
@@ -332,7 +351,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
return Err(Error::NotOgg);
}
if !partial.is_empty() {
warnings.push("file ends inside a packet".into());
warn(&mut warnings, "file ends inside a packet".into());
}
if scanner.spent() {
// once per file and last, so it is not subject to the cap on the others
warnings.push("recovery budget spent; the rest of the damaged data was not searched".into());
}
Ok(OggFile { streams, warnings })
}
@@ -340,12 +363,23 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
struct CaptureScanner {
buf: Vec<u8>,
buf_start: u64,
budget: usize,
budget: u64,
}
impl CaptureScanner {
fn new() -> Self {
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
Self { buf: Vec::new(), buf_start: 0, budget: RECOVERY_BUDGET }
}
/// Charge `bytes` of recovery work; true once the budget is spent.
fn charge(&mut self, bytes: u64) -> bool {
self.budget = self.budget.saturating_sub(bytes);
self.spent()
}
/// Nothing left to search with: fewer bytes than a capture pattern can still be charged for.
fn spent(&self) -> bool {
self.budget < 4
}
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
@@ -360,17 +394,17 @@ impl CaptureScanner {
at = buf_end.saturating_sub(3);
continue;
}
if self.budget == 0 {
if self.spent() {
return Ok(None);
}
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
let n = ((len - at) as usize).min(64 * 1024).min(usize::try_from(self.budget).unwrap_or(usize::MAX));
if n < 4 {
break;
}
self.buf.resize(n, 0);
src.read_at(at, &mut self.buf)?;
self.buf_start = at;
self.budget = self.budget.saturating_sub(n);
self.budget = self.budget.saturating_sub(n as u64);
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
}
+79 -1
View File
@@ -1,6 +1,9 @@
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
use std::cell::Cell;
use std::io;
use super::{ByteSource, CaptureScanner, Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
/// Write one page holding `segments` (lacing already computed by `lace`).
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) {
@@ -196,3 +199,78 @@ fn opus_timing_rejects_a_granule_that_overflows() {
assert_eq!(ok.total, 480);
assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err());
}
/// A source that counts the bytes read from it.
struct Counting<'a> {
data: &'a [u8],
read: Cell<u64>,
}
impl ByteSource for Counting<'_> {
fn len(&self) -> u64 {
self.data.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
self.read.set(self.read.get() + buf.len() as u64);
self.data.read_at(offset, buf)
}
}
/// A valid file followed by `len` bytes of 0xFF with a capture pattern every 64 bytes. Each one
/// claims 255 full lacing values, so it reads as a page of about 64 KiB that fails its CRC.
fn dense_fake_pages(len: usize) -> Vec<u8> {
let mut f = opus_file(6, 312, 0);
let start = f.len();
f.resize(start + len, 0xFF);
for at in (start..start + len - 64).step_by(64) {
f[at..at + 5].copy_from_slice(b"OggS\0");
f[at + 26] = 255;
}
f
}
#[test]
fn recovery_reads_a_bounded_amount_of_a_damaged_file() {
let f = dense_fake_pages(1 << 20);
let src = Counting { data: &f, read: Cell::new(0) };
let o = open(&src).unwrap();
assert_eq!(o.streams[0].packets.len(), 6, "the good pages are kept");
let read = src.read.get();
assert!(read <= 40 << 20, "read {read} bytes of a {} byte file", f.len());
assert!(o.warnings.iter().any(|w| w.contains("recovery budget")), "{:?}", o.warnings);
}
#[test]
fn warnings_are_capped() {
// 100,000 pages with no segments and a wrong CRC, 27 bytes each
let mut f = opus_file(6, 312, 0);
for _ in 0..100_000 {
let at = f.len();
f.extend_from_slice(b"OggS\0\0");
f.resize(at + 27, 0);
f[at + 22] = 1;
}
let o = open(&f).unwrap();
assert!(o.warnings.len() <= 257, "{} warnings", o.warnings.len());
assert!(o.warnings.iter().any(|w| w.contains("omitted")), "{:?}", o.warnings.last());
}
#[test]
fn a_file_of_good_pages_is_not_charged_for_recovery() {
let f = opus_file(2000, 312, 0);
let src = Counting { data: &f, read: Cell::new(0) };
let o = open(&src).unwrap();
assert_eq!(o.streams[0].packets.len(), 2000);
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
}
#[test]
fn a_budget_too_small_to_search_is_spent() {
// 1 to 3 bytes left cannot hold a capture pattern: the scan would stop without saying why
let mut scanner = CaptureScanner::new();
assert!(!scanner.spent());
assert!(scanner.charge(super::RECOVERY_BUDGET - 3));
assert!(scanner.spent());
let f = opus_file(6, 312, 0);
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
}
+19 -3
View File
@@ -451,6 +451,17 @@ fn opaque_px(rgba: &mut [u8]) {
}
}
/// Gives every fully transparent content pixel the backdrop's colour. The renderer leaves them
/// RGB 0,0,0; a viewer that smooths the image blends colour and mask separately, so the black
/// shows as a grey hairline around white pictures and shapes (#1455).
fn fill_clear(front: &mut [u8], back: &[u8]) {
for (f, b) in front.as_chunks_mut::<4>().0.iter_mut().zip(back.as_chunks::<4>().0) {
if f[3] == 0 {
f[..3].copy_from_slice(&b[..3]);
}
}
}
/// Most bytes of opaque backdrop rasters kept to recognise a backdrop seen before.
const BACKDROP_CACHE_BYTES: usize = 32 << 20;
@@ -515,7 +526,8 @@ impl Exporter<'_> {
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
// on every slide that shares its layout, so the writer stores it once, and the slide's own
// shapes draw over it (#845).
let back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts);
let mut back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts);
opaque(&mut back.pixels);
let front = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Content, &ropts);
let (w, h) = (back.width, back.height);
if w > 0
@@ -523,11 +535,15 @@ impl Exporter<'_> {
&& let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32)
{
s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32));
s.draw_image(self.backdrops.image(back), size);
// Taken, not copied: a print-resolution slide is ~16 MB.
let mut rgba = front.pixels;
if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) {
let content = rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0);
if content {
fill_clear(&mut rgba, &back.pixels);
straight(&mut rgba);
}
s.draw_image(self.backdrops.image(back), size);
if content {
s.draw_image(Image::from_rgba8(rgba, w, h), size);
}
s.pop();
+25 -3
View File
@@ -7,8 +7,8 @@ use deckcraft_render::RenderOpts;
use kurbo::Rect;
use super::{
MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces,
master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight,
MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, fill_clear, glyph_text_ranges, handout_boxes,
master_text_pieces, master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight,
};
use deckcraft_model::text::TextBody;
@@ -582,7 +582,20 @@ fn slides_share_one_backdrop_raster_and_keep_their_pixels() {
deckcraft_render::SlidePart::Content,
&RenderOpts { scale: scale(&p), threads: 4, ..Default::default() },
);
let straight = expect.to_straight();
let mut straight = expect.to_straight();
// Fully transparent pixels carry the backdrop colour, not black (#1455).
let mut back = deckcraft_render::render_slide_part(
&p,
0,
deckcraft_render::SlidePart::Backdrop,
&RenderOpts { scale: scale(&p), threads: 4, ..Default::default() },
);
opaque(&mut back.pixels);
for (px, b) in straight.as_chunks_mut::<4>().0.iter_mut().zip(back.pixels.as_chunks::<4>().0) {
if px[3] == 0 {
px[..3].copy_from_slice(&b[..3]);
}
}
let want_rgb: Vec<u8> = straight.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect();
let want_alpha: Vec<u8> = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect();
assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied");
@@ -634,6 +647,15 @@ fn backdrop_and_content_pixels_convert_per_pixel() {
}
}
/// Fully transparent content pixels take the backdrop colour (not black) so a smoothing viewer
/// draws no hairline at the edge of a picture; every other pixel is untouched (#1455).
#[test]
fn transparent_content_pixels_take_the_backdrop_colour() {
let mut front = vec![0, 0, 0, 0, 10, 20, 30, 255, 5, 5, 5, 1, 0, 0, 0, 0];
fill_clear(&mut front, &[255, 250, 240, 255].repeat(4));
assert_eq!(front, [255, 250, 240, 0, 10, 20, 30, 255, 5, 5, 5, 1, 255, 250, 240, 0]);
}
#[test]
fn glyph_ranges_follow_shaping_clusters() {
// "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164).
+22 -2
View File
@@ -329,15 +329,26 @@ pub fn parse_path(d: &str) -> Vec<(char, Vec<f64>)> {
token.clear();
}
};
for ch in d.chars() {
let cs: Vec<char> = d.chars().collect();
for (i, &ch) in cs.iter().enumerate() {
match ch {
// An 'E' inside a number followed by a digit (or a sign and a digit) is an exponent,
// not the end command.
'E' if !token.is_empty() && {
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
} =>
{
token.push(ch)
}
'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z' | 'E' => {
push_tok(&mut token, &mut nums);
flush(cmd, &mut nums, &mut out);
cmd = Some(ch.to_ascii_uppercase());
}
' ' | ',' | '\t' | '\n' => push_tok(&mut token, &mut nums),
'-' if !token.is_empty() && !token.ends_with('e') => {
'-' if !token.is_empty() && !token.ends_with(['e', 'E']) => {
push_tok(&mut token, &mut nums);
token.push(ch);
}
@@ -472,6 +483,15 @@ mod tests {
assert_eq!(back[0].d, "M 0 0 L 100000 0 C 100000 10000 90000 50000 50000 50000 Q 0 50000 0 0 Z");
}
#[test]
fn parse_path_uppercase_exponent() {
assert_eq!(parse_path("M 1E-5 2 L 3 4"), vec![('M', vec![1e-5, 2.0]), ('L', vec![3.0, 4.0])]);
assert_eq!(parse_path("M 1.5E3 2"), vec![('M', vec![1500.0, 2.0])]);
assert_eq!(parse_path("M 1e-5 2"), vec![('M', vec![1e-5, 2.0])]);
// An 'E' right after a number with no exponent digits is the end command.
assert_eq!(parse_path("M 0 0E"), vec![('M', vec![0.0, 0.0]), ('E', vec![])]);
}
#[test]
fn parse_path_tokens() {
let v = parse_path("M0,0 L10-5 Z");
+11
View File
@@ -435,4 +435,15 @@ mod tests {
assert_eq!(effect_for_preset(AnimClass::Entrance, 2), Some("fly"));
assert_eq!(effect_for_preset(AnimClass::Emphasis, 8), Some("spin"));
}
/// PowerPoint's own ids: Darken is 24 and Desaturate 25 (read back from its saved file).
#[test]
fn darken_and_desaturate_use_powerpoints_ids() {
assert_eq!(effect_for_preset(AnimClass::Emphasis, 24), Some("darken"));
assert_eq!(effect_for_preset(AnimClass::Emphasis, 25), Some("desaturate"));
for (effect, id) in [("darken", 24), ("desaturate", 25)] {
let a = deckcraft_model::Animation { class: AnimClass::Emphasis, effect: effect.into(), ..Default::default() };
assert_eq!(preset_for(&a), id, "{effect}");
}
}
}
+10 -1
View File
@@ -93,7 +93,8 @@ impl Package {
}
}
total = total.saturating_add(data.len() as u64);
parts.insert(name.to_ascii_lowercase(), (name, data));
// A name kept with `\` would reach code that splits paths on `/`.
parts.insert(norm(&name), (name.replace('\\', "/"), data));
}
Ok(Package { parts, ct_cache: std::sync::OnceLock::new() })
}
@@ -499,6 +500,14 @@ mod tests {
assert!(p.get("e").is_some());
}
#[test]
fn backslash_zip_entry_names_are_found() {
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]);
let p = Package::open(&z).unwrap();
assert!(p.get("ppt/slides/slide1.xml").is_some());
assert_eq!(p.original_name("ppt/slides/slide1.xml"), Some("PPT/Slides/Slide1.xml"));
}
#[test]
fn xml_bytes_applies_the_xml_limit() {
let z = zip_of(&[("small.xml", 10)]);
+1
View File
@@ -1205,6 +1205,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
}
if let Some(t) = root.child("timing") {
s.animations = timing::animations(imp, part, &ctx, t, &s.shapes);
timing::media_settings(t, &ctx, &mut s.shapes);
}
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
s.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
+27 -3
View File
@@ -360,7 +360,11 @@ fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option<String>) -> (Op
Some(n) if n.attr("sourceLinked") != Some("1") => n.attr("formatCode").map(str::to_string),
n => source_format(a).or_else(|| n.and_then(|n| n.attr("formatCode")).map(str::to_string)),
};
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true);
// `c:tickLblPos val="none"` hides the labels just as `c:delete` does.
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true) || a.child_val("tickLblPos") == Some("none");
// A gridline whose `c:spPr/a:ln` has `a:noFill` is not drawn, though the element is there.
let grid =
|a: &El, k: &str| a.child(k).is_some_and(|g| !g.child("spPr").and_then(|p| p.child("ln")).is_some_and(|l| l.child("noFill").is_some()));
if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) {
let primary_id = val_ax.and_then(|a| a.child_val("axId"));
// The other vertical value axis is the secondary one.
@@ -375,8 +379,8 @@ fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option<String>) -> (Op
}
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
if let Some(v) = val_ax {
c.gridlines = v.child("majorGridlines").is_some();
c.minor_gridlines = v.child("minorGridlines").is_some();
c.gridlines = grid(v, "majorGridlines");
c.minor_gridlines = grid(v, "minorGridlines");
let sc = v.child("scaling");
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
@@ -769,6 +773,26 @@ mod tests {
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
}
#[test]
fn hidden_labels_and_unfilled_gridlines_are_read() {
let xml = |ax: &str| {
format!(
r#"<c:chartSpace xmlns:c="{NS_C}" xmlns:a="{NS_A}"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/>{ax}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#
)
};
let no_fill = r#"<c:spPr><a:ln w="9525"><a:noFill/></a:ln></c:spPr>"#;
let c = read_chart_xml(xml(r#"<c:majorGridlines/><c:minorGridlines/><c:tickLblPos val="nextTo"/>"#).as_bytes()).unwrap();
assert!(c.gridlines && c.minor_gridlines && !c.y_hidden);
let c = read_chart_xml(
xml(&format!(
r#"<c:majorGridlines>{no_fill}</c:majorGridlines><c:minorGridlines>{no_fill}</c:minorGridlines><c:tickLblPos val="none"/>"#
))
.as_bytes(),
)
.unwrap();
assert!(!c.gridlines && !c.minor_gridlines && c.y_hidden);
}
#[test]
fn trendlines_are_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx>
+45 -15
View File
@@ -3,8 +3,8 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_geom::{Xfrm, emu_to_pt};
use deckcraft_model::style::{
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, PatternFill,
PictureFill, PictureMode, Reflection, Shadow,
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, LineCap, LineEnd,
LineJoin, PatternFill, PictureFill, PictureMode, Reflection, Shadow,
};
use deckcraft_model::text::{
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing,
@@ -122,7 +122,13 @@ pub fn gradient(f: &El) -> Gradient {
GradientShape::Linear { angle: 90.0, scaled: false }
};
let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0)));
Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
Gradient {
tile: tile.unwrap_or_default(),
stops,
shape,
flip: f.attr("flip").and_then(GradientFlip::from_xml),
rotate_with_shape: f.bool("rotWithShape").unwrap_or(true),
}
}
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.
@@ -222,6 +228,7 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
_ => LineCap::Flat,
});
l.compound = ln.attr("cmpd").map(Compound::from_xml);
l.align = ln.attr("algn").and_then(LineAlign::from_xml);
l.fill = fill_of(imp, part, ln);
for e in ln.elements() {
match e.local() {
@@ -232,7 +239,10 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
}
"round" => l.join = Some(LineJoin::Round),
"bevel" => l.join = Some(LineJoin::Bevel),
"miter" => l.join = Some(LineJoin::Miter),
"miter" => {
l.join = Some(LineJoin::Miter);
l.miter_limit = e.attr("lim").and_then(parse_miter_limit);
}
"headEnd" => l.head = line_end(e),
"tailEnd" => l.tail = line_end(e),
_ => {}
@@ -241,6 +251,17 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
l
}
fn parse_miter_limit(value: &str) -> Option<u32> {
const MAX: u32 = i32::MAX as u32;
let value = value.trim();
if let Some(percent) = value.strip_suffix('%') {
let units = percent.trim().parse::<f64>().ok()? * 1000.0;
(units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32)
} else {
value.parse::<u32>().ok().filter(|limit| *limit <= MAX)
}
}
fn line_end(e: &El) -> Option<LineEnd> {
let kind = e.attr("type").unwrap_or("none");
Some(LineEnd { kind: kind.to_string(), w: e.attr("w").unwrap_or("med").to_string(), len: e.attr("len").unwrap_or("med").to_string() })
@@ -431,8 +452,11 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
}
}
"buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
"buFontTx" => pp.bullet_font_follow_text = Some(true),
"buClr" => pp.bullet_color = color(e),
"buClrTx" => pp.bullet_color_follow_text = Some(true),
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
"buSzTx" => pp.bullet_size_follow_text = Some(true),
"tabLst" => {
pp.tabs = Some(
e.children_named("tab")
@@ -631,18 +655,14 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
let text: String = e.child("t").map(|t| t.text()).unwrap_or_default();
push_text_runs(&mut para, props, &text);
}
"br" => {
if para.runs.len() < MAX_PARAGRAPH_RUNS {
let props = run_props(imp, part, e);
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
}
"br" if para.runs.len() < MAX_PARAGRAPH_RUNS => {
let props = run_props(imp, part, e);
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
}
"fld" => {
if para.runs.len() < MAX_PARAGRAPH_RUNS {
let props = run_props(imp, part, e);
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
}
"fld" if para.runs.len() < MAX_PARAGRAPH_RUNS => {
let props = run_props(imp, part, e);
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
}
"m" => push_math_run(&mut para, part, e),
"endParaRPr" => para.end_props = rpr(imp, part, e),
@@ -672,6 +692,16 @@ mod tests {
assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) });
}
#[test]
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
assert_eq!(parse_miter_limit("200000"), Some(200_000));
assert_eq!(parse_miter_limit("200%"), Some(200_000));
assert_eq!(parse_miter_limit("1000001"), Some(1_000_001));
assert_eq!(parse_miter_limit("malformed"), None);
assert_eq!(parse_miter_limit("2147483648"), None);
assert_eq!(parse_miter_limit("2147483.648%"), None);
}
#[test]
fn body_props() {
let d = parse(br#"<a:bodyPr lIns="91440" anchor="ctr" wrap="none" vert="vert270" numCol="2"><a:normAutofit fontScale="62500" lnSpcReduction="20000"/></a:bodyPr>"#).unwrap();
+2
View File
@@ -338,6 +338,8 @@ fn pic(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);
}
// A picture in a picture placeholder is cut into the layout's shape (a circle, say).
mark_inherited_geometry(&mut s, e.child("spPr"));
s.style = e.child("style").map(style);
let pf = e.child("blipFill").and_then(|b| dml::blip_fill(imp, part, b));
let nvpr = e.path(&["nvPicPr", "nvPr"]);
+40 -2
View File
@@ -1,7 +1,7 @@
//! Slide transitions (`p:transition`) and animations (`p:timing`).
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, TextBody, find_shape};
use deckcraft_model::{Shape, ShapeKind, TextBody, find_shape, find_shape_mut};
use super::shapes::IdCtx;
use super::{Imp, Part, dml};
@@ -94,6 +94,42 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {
best
}
/// Does every behaviour time node (`p:cBhvr/p:cTn`) under the effect node `c` carry `autoRev`?
fn behaviour_auto_reverses(c: &El) -> bool {
let mut behaviours = vec![];
c.find_all("cBhvr", &mut behaviours);
!behaviours.is_empty() && behaviours.iter().all(|b| b.child("cTn").and_then(|t| t.bool("autoRev")).unwrap_or(false))
}
/// Volume, mute, Loop until Stopped, Hide While Not Playing and Play Full Screen live in the timing tree
/// (`p:video`/`p:audio` > `p:cMediaNode`), not on the picture: copy them onto the target's `MediaClip`.
pub fn media_settings(timing: &El, ctx: &IdCtx, shapes: &mut [Shape]) {
let mut nodes = vec![];
for tag in ["video", "audio"] {
timing.find_all(tag, &mut nodes);
}
for node in nodes.into_iter().take(10_000) {
let Some(cm) = node.child("cMediaNode") else { continue };
let Some(id) = cm.path(&["tgtEl", "spTgt"]).and_then(|t| t.u32("spid")).and_then(|f| ctx.lookup(f)) else { continue };
let Some(ShapeKind::Media(clip)) = find_shape_mut(shapes, id).map(|s| &mut s.kind) else { continue };
if let Some(v) = cm.attr("vol").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()) {
clip.volume = (v / 100_000.0).clamp(0.0, 1.0);
}
if cm.bool("mute") == Some(true) {
clip.volume = 0.0;
}
if cm.bool("showWhenStopped") == Some(false) {
clip.hide_while_not_playing = true;
}
if cm.child("cTn").and_then(|c| c.attr("repeatCount")) == Some("indefinite") {
clip.loop_play = true;
}
if node.bool("fullScrn") == Some(true) {
clip.full_screen = true;
}
}
}
/// Flatten the timing tree into the Animation Pane list.
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec<Animation> {
let _ = (imp, part);
@@ -144,7 +180,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
trigger: f.trigger.and_then(|t| ctx.lookup(t)),
preset_id: Some(preset),
preset_subtype: Some(sub),
auto_reverse: c.bool("autoRev").unwrap_or(false),
// PowerPoint's "Auto-reverse" is `autoRev` on the behaviour nodes (`p:cBhvr/p:cTn`); Color Pulse
// always writes it there and plays back and forth on its own, so it isn't read as the option.
auto_reverse: c.bool("autoRev").unwrap_or(false) || (effect != "colorPulse" && behaviour_auto_reverses(c)),
// PowerPoint's "Rewind when done playing" is `fill="remove"` on the effect node.
rewind: c.attr("fill") == Some("remove"),
smooth_start: c.f64("accel").map(|v| v / 100_000.0).unwrap_or(0.0),
+21 -6
View File
@@ -1,7 +1,9 @@
//! DrawingML writing: colours, fills, lines, effects, transforms and text.
use deckcraft_geom::{Xfrm, pt_to_emu};
use deckcraft_model::style::{ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode};
use deckcraft_model::style::{
ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode,
};
use deckcraft_model::text::{
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody,
};
@@ -208,6 +210,7 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
LineCap::Flat => "flat",
}),
)
.o("algn", l.align.map(LineAlign::xml))
.o("cmpd", l.compound.map(Compound::xml));
w.open(tag, a);
if let Some(f) = &l.fill {
@@ -233,7 +236,10 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
match l.join {
Some(LineJoin::Round) => w.empty0("a:round"),
Some(LineJoin::Bevel) => w.empty0("a:bevel"),
Some(LineJoin::Miter) => w.empty0("a:miter"),
Some(LineJoin::Miter) => {
let limit = l.miter_limit.filter(|limit| *limit <= i32::MAX as u32);
w.empty("a:miter", A::new().o("lim", limit));
}
None => {}
}
for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] {
@@ -279,7 +285,7 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
}
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
w.open("a:gradFill", A::new().o("flip", g.flip.map(GradientFlip::xml)).a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
let mut stops = g.stops.clone();
if stops.len() == 1 {
@@ -533,8 +539,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|| p.space_after.is_some()
|| p.bullet.is_some()
|| p.bullet_font.is_some()
|| p.bullet_font_follow_text.is_some()
|| p.bullet_color.is_some()
|| p.bullet_color_follow_text.is_some()
|| p.bullet_size.is_some()
|| p.bullet_size_follow_text.is_some()
|| p.tabs.is_some()
|| def.is_some();
if !has_kids {
@@ -551,15 +560,21 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
if let Some(s) = &p.space_after {
spacing(w, "a:spcAft", s);
}
if let Some(c) = &p.bullet_color {
if p.bullet_color.is_none() && p.bullet_color_follow_text == Some(true) {
w.empty0("a:buClrTx");
} else if let Some(c) = &p.bullet_color {
w.open0("a:buClr");
color(w, c);
w.close("a:buClr");
}
if let Some(s) = p.bullet_size {
if p.bullet_size.is_none() && p.bullet_size_follow_text == Some(true) {
w.empty0("a:buSzTx");
} else if let Some(s) = p.bullet_size {
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
}
if let Some(f) = &p.bullet_font {
if p.bullet_font.is_none() && p.bullet_font_follow_text == Some(true) {
w.empty0("a:buFontTx");
} else if let Some(f) = &p.bullet_font {
w.empty("a:buFont", A::new().a("typeface", f));
}
match &p.bullet {
+13 -1
View File
@@ -383,7 +383,19 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) {
w.close("p:animClr");
}
}
"fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "darken" | "lighten" => {
// As PowerPoint writes them: a relative HSL shift of text, fill and line colour.
"darken" | "desaturate" => {
let (s, l) = if a.effect == "darken" { (0, -25_098) } else { (-70_588, 0) };
for attr in ["style.color", "fillcolor", "stroke.color"] {
w.open("p:animClr", A::new().a("clrSpc", "hsl").a("dir", "cw"));
cbhvr(w, ids, e, dur, None, &[attr], None);
w.open0("p:by");
w.empty("p:hsl", A::new().a("h", 0).a("s", s).a("l", l));
w.close("p:by");
w.close("p:animClr");
}
}
"fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "lighten" => {
let attr = match a.effect.as_str() {
"fontColor" => "style.color",
"lineColor" => "stroke.color",
+44
View File
@@ -0,0 +1,44 @@
//! "Auto-reverse" is written by PowerPoint on the behaviour time nodes, not on the effect node.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, Animation};
use deckcraft_model::{Shape, ShapeId, defaults};
fn deck_with(effect: &str, behaviour: &str) -> Vec<bool> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("id"));
let shape = Shape { id, name: "Box".into(), xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), ..Default::default() };
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
sl.shapes = vec![shape];
sl.animations.push(Animation { shape: id, class: AnimClass::Emphasis, effect: effect.into(), duration_ms: 1000, ..Default::default() });
let mut z = zip::ZipArchive::new(Cursor::new(deckcraft_pptx::export(&p).expect("export"))).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if name == "ppt/slides/slide1.xml" {
let xml = String::from_utf8(d).expect("utf8");
let at = xml.find(behaviour).expect("behaviour written");
let cb = at + xml[at..].find("<p:cTn ").expect("behaviour node");
d = format!("{}<p:cTn autoRev=\"1\" {}", &xml[..cb], &xml[cb + "<p:cTn ".len()..]).into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&d).expect("write");
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
q.slides[0].animations.iter().map(|a| a.auto_reverse).collect()
}
#[test]
fn grow_shrink_reads_auto_reverse_from_its_behaviour_node() {
assert_eq!(deck_with("growShrink", "<p:animScale>"), [true]);
}
#[test]
fn color_pulse_behaviour_auto_reverse_is_not_the_option() {
assert_eq!(deck_with("colorPulse", "<p:animClr "), [false]);
}
+2
View File
@@ -46,6 +46,7 @@ fn gradient(angle: f64) -> Fill {
shape: GradientShape::Linear { angle, scaled: true },
rotate_with_shape: true,
tile: [0.0; 4],
flip: None,
})
}
@@ -116,6 +117,7 @@ fn style_deck() -> Presentation {
shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] },
rotate_with_shape: false,
tile: [0.0; 4],
flip: None,
}));
let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0);
+4 -1
View File
@@ -88,7 +88,8 @@ fn a_gradient_without_stops_is_saved_without_stops() {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let own =
Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true, flip: None };
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
@@ -119,6 +120,7 @@ fn a_fill_overlay_round_trips() {
],
shape: GradientShape::Linear { angle: 4.5, scaled: true },
rotate_with_shape: true,
flip: None,
}),
};
p.slide_mut(first).expect("slide").shapes.push(Shape {
@@ -148,6 +150,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() {
shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
rotate_with_shape: true,
tile: [-1.0, -1.0, 0.0, 0.0],
flip: None,
};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("ids"));
+2
View File
@@ -1,4 +1,5 @@
mod anchor;
mod anim_auto_reverse;
mod chart_embed;
mod comments;
mod corpus;
@@ -13,6 +14,7 @@ mod kept_parts;
mod locks;
mod malformed;
mod math_ns;
mod media_timing;
mod notes;
mod ole;
mod ole_vml;
+76
View File
@@ -0,0 +1,76 @@
//! Media settings PowerPoint keeps in the timing tree, spliced into an exported deck and read back.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::{MediaClip, ShapeId, ShapeKind, defaults};
fn node(tag: &str, attrs: &str, spid: u32, media: &str, ctn: &str) -> String {
format!(
r#"<p:par><p:cTn id="2" fill="hold"><p:childTnLst><p:{tag}{attrs}><p:cMediaNode{media}><p:cTn id="3"{ctn}/><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl></p:cMediaNode></p:{tag}></p:childTnLst></p:cTn></p:par>"#
)
}
fn clips(nodes: impl Fn(u32, u32) -> String) -> Vec<MediaClip> {
let mut p = defaults::new_presentation(None);
let video = p.add_media("clip.mp4", "video/mp4", b"video".to_vec()).expect("media");
let audio = p.add_media("clip.m4a", "audio/mp4", b"audio".to_vec()).expect("media");
let mut ids = vec![];
for (media, is_video) in [(video, true), (audio, false)] {
let id = ShapeId(p.alloc_id().expect("id"));
ids.push(id.0);
let s = deckcraft_model::Shape {
id,
name: "Clip".into(),
xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)),
kind: ShapeKind::Media(MediaClip { media, video: is_video, volume: 1.0, ..Default::default() }),
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes.push(s);
}
let timing = format!(
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>"#,
nodes(ids[0], ids[1])
);
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
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");
if f.name() == "ppt/slides/slide1.xml" {
let s = String::from_utf8(d).expect("utf8");
assert!(!s.contains("<p:timing"));
let end = s.rfind("</p:sld>").expect("end");
d = format!("{}{timing}{}", &s[..end], &s[end..]).into_bytes();
}
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&d).expect("write");
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
q.slides[0]
.shapes
.iter()
.filter_map(|s| match &s.kind {
ShapeKind::Media(m) => Some(m.clone()),
_ => None,
})
.collect()
}
#[test]
fn video_settings_are_read_from_the_timing_tree() {
let c = clips(|v, _| node("video", r#" fullScrn="1""#, v, r#" vol="80000" showWhenStopped="0""#, r#" repeatCount="indefinite""#));
assert_eq!((c[0].volume, c[0].hide_while_not_playing, c[0].loop_play, c[0].full_screen), (0.8, true, true, true));
// The sound next to it has no node: defaults stay.
assert_eq!((c[1].volume, c[1].hide_while_not_playing, c[1].loop_play, c[1].full_screen), (1.0, false, false, false));
}
#[test]
fn muted_audio_has_no_volume_and_other_clips_keep_theirs() {
let c = clips(|_, a| node("audio", "", a, r#" vol="50000" mute="1""#, ""));
assert_eq!(c[0].volume, 1.0);
assert_eq!((c[1].volume, c[1].loop_play, c[1].full_screen), (0.0, false, false));
}
+47 -1
View File
@@ -2,7 +2,9 @@
use std::io::{Cursor, Read, Write};
use deckcraft_model::{Geom, defaults};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Geom, PhType, Placeholder, Shape, ShapeId, ShapeKind, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
@@ -62,3 +64,47 @@ fn a_placeholder_without_geometry_takes_its_layouts() {
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")));
}
#[test]
fn a_picture_placeholder_without_geometry_takes_its_layouts() {
// The slide's picture sits in a layout's picture placeholder drawn as an ellipse; PowerPoint
// writes its spPr with a frame only and shows the picture cut into the circle.
let mut p = defaults::new_presentation(None);
let img = {
let mut out = Cursor::new(vec![]);
let px = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
image::DynamicImage::ImageRgb8(px).write_to(&mut out, image::ImageFormat::Png).expect("png");
p.add_media("image1.png", "image/png", out.into_inner()).expect("media")
};
let ph = Placeholder { kind: PhType::Picture, idx: 15, ..Default::default() };
let layout = p.slides[0].layout;
let slot = Shape {
id: ShapeId(p.alloc_id().expect("ids")),
name: "Picture Placeholder".into(),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)),
geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },
ph: Some(ph.clone()),
..Default::default()
};
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").shapes.push(slot);
let pic = Shape {
id: ShapeId(p.alloc_id().expect("ids")),
name: "Photo".into(),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
ph: Some(ph),
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![pic];
let bytes = deckcraft_pptx::export(&p).expect("export");
let bare = edit_slides(&bytes, |mut 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 photo = q.slides[0].shapes.iter().find(|s| matches!(s.kind, ShapeKind::Picture { .. })).expect("picture");
assert!(matches!(&photo.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", photo.geom);
}
+144 -2
View File
@@ -7,8 +7,8 @@ use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::{
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode,
Shadow,
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill,
PictureMode, Shadow,
};
use deckcraft_model::table::Table;
use deckcraft_model::text::{
@@ -115,6 +115,8 @@ pub fn rich_deck() -> Presentation {
],
shape: GradientShape::Linear { angle: 45.0, scaled: false },
rotate_with_shape: true,
flip: Some(GradientFlip::Xy),
}));
a.line = Some(Line { width: Some(3.0), dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)), 3.0) });
a.effects = Some(Effects {
@@ -488,6 +490,102 @@ fn shapes_fills_lines_effects_round_trip() {
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
}
#[test]
fn line_miter_limit_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut limited = shape(&mut p, "Limited miter", 20.0, 20.0, 100.0, 100.0);
limited.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(200_000), ..Default::default() });
let mut defaulted = shape(&mut p, "Default miter", 140.0, 20.0, 100.0, 100.0);
defaulted.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..Default::default() });
let mut large = shape(&mut p, "Large miter", 260.0, 20.0, 100.0, 100.0);
large.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(1_000_001), ..Default::default() });
Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted, large]);
let q = round(&round(&p));
assert_eq!(by_name(&q.slides[0], "Limited miter").line.as_ref().and_then(|line| line.miter_limit), Some(200_000));
assert_eq!(by_name(&q.slides[0], "Default miter").line.as_ref().and_then(|line| line.miter_limit), None);
assert_eq!(by_name(&q.slides[0], "Large miter").line.as_ref().and_then(|line| line.miter_limit), Some(1_000_001));
}
#[test]
fn bullet_follow_text_resets_survive_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut textbox = shape(&mut p, "Follow text bullets", 20.0, 20.0, 400.0, 100.0);
textbox.text = Some(TextBody {
paragraphs: vec![Paragraph {
props: deckcraft_model::text::ParaProps {
bullet: Some(Bullet::Char { char: "•".into() }),
bullet_color_follow_text: Some(true),
bullet_size_follow_text: Some(true),
bullet_font_follow_text: Some(true),
..Default::default()
},
runs: vec![Run::with("Bullet text", RunProps::default())],
..Default::default()
}],
..Default::default()
});
Arc::make_mut(&mut p.slides[0]).shapes.push(textbox);
let mut conflicting = shape(&mut p, "Explicit bullet values", 20.0, 140.0, 400.0, 100.0);
conflicting.text = Some(TextBody {
paragraphs: vec![Paragraph {
props: deckcraft_model::text::ParaProps {
bullet: Some(Bullet::Char { char: "•".into() }),
bullet_color: Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))),
bullet_color_follow_text: Some(true),
bullet_size: Some(1.5),
bullet_size_follow_text: Some(true),
bullet_font: Some("Inter".into()),
bullet_font_follow_text: Some(true),
..Default::default()
},
runs: vec![Run::with("Explicit bullet text", RunProps::default())],
..Default::default()
}],
..Default::default()
});
Arc::make_mut(&mut p.slides[0]).shapes.push(conflicting);
let q = round(&round(&p));
let props = &by_name(&q.slides[0], "Follow text bullets").text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.bullet_color_follow_text, Some(true));
assert_eq!(props.bullet_size_follow_text, Some(true));
assert_eq!(props.bullet_font_follow_text, Some(true));
let explicit = &by_name(&q.slides[0], "Explicit bullet values").text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(explicit.bullet_color_follow_text, None);
assert_eq!(explicit.bullet_size_follow_text, None);
assert_eq!(explicit.bullet_font_follow_text, None);
assert_eq!(explicit.bullet_size, Some(1.5));
assert_eq!(explicit.bullet_font.as_deref(), Some("Inter"));
assert_eq!(explicit.bullet_color, Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
}
#[test]
fn line_alignment_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut inside = shape(&mut p, "Inset line", 20.0, 20.0, 100.0, 100.0);
inside.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Inside), ..Default::default() });
let mut centered = shape(&mut p, "Centered line", 140.0, 20.0, 100.0, 100.0);
centered.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Center), ..Default::default() });
Arc::make_mut(&mut p.slides[0]).shapes.extend([inside, centered]);
let q = round(&round(&p));
assert_eq!(by_name(&q.slides[0], "Inset line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Inside));
assert_eq!(by_name(&q.slides[0], "Centered line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Center));
assert_eq!(deckcraft_model::style::LineAlign::from_xml("out"), None);
}
#[test]
fn gradient_flip_mode_survives_two_pptx_round_trips() {
let p = rich_deck();
let q = round(&round(&p));
let gradient = by_name(&q.slides[1], "Gradient Star").fill.as_ref();
assert!(matches!(gradient, Some(Fill::Gradient(g)) if g.flip == Some(GradientFlip::Xy)));
}
#[test]
fn pictures_media_and_ink_round_trip() {
let p = rich_deck();
@@ -1925,3 +2023,47 @@ fn zoom_inserts_linked_slide_pictures() {
assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err());
assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err());
}
#[test]
fn darken_and_desaturate_write_powerpoints_hsl_shift() {
for (effect, preset, shift) in [("darken", 24, r#"s="0" l="-25098""#), ("desaturate", 25, r#"s="-70588" l="0""#)] {
let mut p = defaults::new_presentation(None);
let mut s = p.slides[0].as_ref().clone();
let sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0);
let id = sh.id;
s.shapes.push(sh);
s.animations.push(Animation { shape: id, class: AnimClass::Emphasis, effect: effect.into(), duration_ms: 500, ..Default::default() });
p.slides[0] = Arc::new(s);
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(&format!(r#"presetID="{preset}""#)), "{effect}");
assert!(xml.contains(r#"clrSpc="hsl""#) && xml.contains(shift), "{effect}: {xml}");
assert_eq!(round(&p).slides[0].animations[0].effect, effect);
}
}
/// PowerPoint's default durations (COM `AddEffect`), which differ from the 0.5 s most effects get.
#[test]
fn default_durations_match_powerpoint() {
for (effect, ms) in [
("grow", 1000),
("shrinkTurn", 1000),
("box", 2000),
("circle", 2000),
("diamond", 2000),
("boxOut", 2000),
("diamondOut", 2000),
("expand", 1000),
("contract", 1000),
("spinner", 500),
("spinnerOut", 500),
("colorPulse", 250),
("objectColor", 500),
("boldFlash", 2000),
] {
let a = deckcraft_model::anim::ANIMATIONS.iter().find(|a| a.0 == effect).unwrap();
assert_eq!(a.4, ms, "{effect}");
}
}
+26 -2
View File
@@ -1040,8 +1040,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap));
}
}
if c.series_data_labels(s) {
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
if let Some(t) = bar_data_label(c, s, i) {
let lr = if horiz {
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
} else {
@@ -1068,6 +1067,31 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
err_marks
}
fn bar_data_label(c: &Chart, s: &deckcraft_model::chart::Series, i: usize) -> Option<String> {
if !c.series_data_labels(s) {
return None;
}
s.values.get(i).copied().flatten().map(|value| c.data_label(s, value))
}
#[cfg(test)]
mod bar_label_tests {
use super::bar_data_label;
use deckcraft_model::chart::{Chart, Series};
#[test]
fn missing_bar_values_have_no_label_but_explicit_zero_does() {
let chart = Chart { data_labels: true, ..Default::default() };
let series = Series { values: [Some(1.0), None, Some(3.0)].into(), ..Default::default() };
let labels: Vec<_> = (0..series.values.len()).filter_map(|i| bar_data_label(&chart, &series, i)).collect();
assert_eq!(labels, ["1", "3"]);
let zero = Series { values: [Some(0.0)].into(), ..Default::default() };
assert_eq!(bar_data_label(&chart, &zero, 0).as_deref(), Some("0"));
}
}
fn draw_pie(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
let Some(s) = c.series.first() else { return };
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
+18
View File
@@ -199,3 +199,21 @@ pub(crate) fn scale_of(m: Affine) -> f64 {
pub(crate) fn blur(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
}
/// The widest a shape may reach on screen, in pixels, and still be drawn: ten thousand screens
/// across, far past any real deck, and still cheap for vello to flatten (#1165).
const MAX_DEVICE_EXTENT: f64 = 1e7;
/// Whether a shape's geometry can go to vello. Geometry far beyond any screen (a size at the EMU
/// limit, group scales compounding, custom-geometry guides) makes vello's flattener size a buffer
/// by its on-screen length, and the allocation aborts the process (#1165). Each path is checked
/// for finite points first: a bounding box drops NaN, and a union with the frame would hide it.
/// The extent is the control box, which always holds the curve: the tight bounding box can miss
/// a curve's extremum when finding it overflows.
pub(crate) fn drawable(geo: &Geometry, m: Affine, w: f64, h: f64) -> bool {
if !geo.paths.iter().all(|p| p.path.is_finite()) {
return false;
}
let dev = m.transform_rect_bbox(geo.outline().control_box().union(Rect::new(0.0, 0.0, w, h)));
dev.is_finite() && dev.width().max(dev.height()) <= MAX_DEVICE_EXTENT
}
+1
View File
@@ -95,6 +95,7 @@ impl Default for RenderOpts<'_> {
}
}
#[derive(Clone)]
pub(crate) struct SlideFields {
pub(crate) num: u32,
/// Text for every date field instead of the date ([`RenderOpts::date_text`]).
+13 -5
View File
@@ -1,4 +1,6 @@
use super::geometry::{Caster, blur, border_outside, camera_turn, color, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry};
use super::geometry::{
Caster, blur, border_outside, camera_turn, color, drawable, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry,
};
use super::opts::{RenderOpts, ShapeState, SlideFields};
use super::raster::Image;
use super::slide::{SlidePart, needs_filters, output_size};
@@ -134,6 +136,10 @@ impl Renderer {
/// 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) {
// Backdrop graphics never take shape states: their ids may clash with the slide's shapes.
let bopts = RenderOpts { state: None, date_text: f.opts.date_text.clone(), ..*f.opts };
let bf = Frame { pres: f.pres, opts: &bopts, fields: f.fields.clone() };
let f = &bf;
self.background(ctx, f, rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
@@ -209,14 +215,12 @@ impl Renderer {
self.in_shadow_pass = false;
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) };
let f = Frame { pres, opts, 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 f = Frame { pres, opts, 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);
@@ -585,6 +589,10 @@ impl Renderer {
Some(pm) => self.morphs.geometry(pm, s, w, h),
None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()),
};
if !drawable(&geo, m, w, h) {
log::warn!("render: shape {} skipped: its outline is not finite or far larger than any screen", s.id.0);
return;
}
let (fill, fill_ph) = resolve::fill(rctx, s);
let (line, line_ph) = resolve::line(rctx, s);
let (effects, fx_ph) = resolve::effects(rctx, s);
+83
View File
@@ -978,6 +978,7 @@ fn a_linear_gradient_ramps_like_powerpoint() {
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,
flip: None,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
@@ -1003,6 +1004,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
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,
flip: None,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
@@ -1044,6 +1046,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() {
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,
flip: None,
}),
});
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
@@ -1145,6 +1148,7 @@ fn a_text_gradient_starts_over_on_every_line() {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
flip: None,
}));
}
sh.text = Some(t);
@@ -1306,6 +1310,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,
flip: None,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
@@ -1776,6 +1781,28 @@ fn the_editor_pasteboard_draws_shapes_off_the_slide() {
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
}
#[test]
fn animation_state_skips_layout_and_master_shapes_sharing_a_slide_shape_id() {
let mut p = Presentation::default();
let bar = |x| Shape {
id: ShapeId(77),
xfrm: Some(Xfrm::new(x, 400.0, 100.0, 60.0)),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))),
..Default::default()
};
let lid = p.slides[0].layout;
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
m.shapes.push(bar(100.0));
m.layouts.iter_mut().find(|l| l.id == lid).expect("layout").shapes.push(bar(400.0));
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.push(bar(700.0));
let hide = |_: ShapeId| ShapeState { visible: false, ..Default::default() };
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&hide), ..Default::default() });
for x in [150, 450] {
assert_eq!(img.pixel(x, 430)[..3], [200, 0, 0], "the backdrop bar at x={x} stays");
}
assert_ne!(img.pixel(750, 430)[..3], [200, 0, 0], "the slide's own shape with that id is hidden");
}
#[test]
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
let mut p = Presentation::default();
@@ -2951,3 +2978,59 @@ fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() {
assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red));
assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before");
}
/// #1165: an ellipse sized at the EMU limit, crossing the slide, made vello's flattener allocate a
/// buffer from its on-screen length and abort. It is skipped; the rest of the slide still draws.
#[test]
fn a_shape_far_larger_than_any_screen_is_skipped_not_flattened() {
let huge = Shape {
id: ShapeId(501),
geom: Geom::preset("ellipse"),
xfrm: Some(Xfrm::new(-5e17, -5e17, 1e18, 1e18)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
..Default::default()
};
let normal = Shape {
id: ShapeId(502),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let p = deck_with(vec![huge, normal]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
let px = img.pixel(200, 150);
assert!((px[0] as i32 - accent.r as i32).abs() <= 2, "{px:?} vs {accent:?}");
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the huge ellipse is not drawn");
}
/// #1165: a path with a NaN point is not drawn, even when the shape's frame is finite and small
/// (a bounding box, or a union with the frame, would hide the NaN).
#[test]
fn a_shape_whose_outline_is_not_finite_is_not_drawable() {
let geo = |pts: &[(f64, f64)]| {
let mut path = kurbo::BezPath::new();
path.move_to(pts[0]);
for p in &pts[1..] {
path.line_to(*p);
}
deckcraft_geom::preset::Geometry {
paths: vec![deckcraft_geom::preset::SubPath { path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true, even_odd: false }],
text_rect: kurbo::Rect::ZERO,
handles: vec![],
sites: vec![],
}
};
let m = kurbo::Affine::IDENTITY;
assert!(crate::geometry::drawable(&geo(&[(0.0, 0.0), (100.0, 50.0)]), m, 100.0, 50.0));
assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (f64::NAN, 50.0)]), m, 100.0, 50.0));
assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (f64::INFINITY, 50.0)]), m, 100.0, 50.0));
assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (1e18, 50.0)]), m, 100.0, 50.0));
// A quadratic whose extremum overflows kurbo's bounding box: its control point still counts.
let mut path = kurbo::BezPath::new();
path.move_to((0.0, 0.0));
path.quad_to((1e308, 0.0), (0.0, 0.0));
let mut quad = geo(&[(0.0, 0.0), (1.0, 1.0)]);
quad.paths[0].path = path;
assert!(!crate::geometry::drawable(&quad, m, 100.0, 50.0));
}
+17 -3
View File
@@ -186,6 +186,7 @@ fn radial(focus: [f64; 4]) -> Shape {
stops,
shape: GradientShape::Path { path: "circle".into(), focus },
rotate_with_shape: true,
flip: None,
})),
line: no_line(),
..Default::default()
@@ -310,8 +311,13 @@ fn a_pictures_border_lies_outside_the_picture() {
fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
let grad =
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
let grad = Fill::Gradient(Gradient {
tile: [0.0; 4],
stops,
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
flip: None,
});
let child = |id: u32, x: f64| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)),
@@ -372,7 +378,13 @@ fn a_linear_gradient_spans_its_tile_rect() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
// Tile rect the left half of the box: the ramp ends half way and stays black after.
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] };
let g = Gradient {
stops,
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
tile: [0.0, 0.0, 0.5, 0.0],
flip: None,
};
let sh = Shape {
id: ShapeId(20),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)),
@@ -502,6 +514,7 @@ fn gradient_lines_and_their_heads_take_the_gradient() {
shape: GradientShape::Linear { angle, scaled: false },
rotate_with_shape: true,
tile: [0.0; 4],
flip: None,
})
};
// Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at
@@ -656,6 +669,7 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() {
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape,
tile: [0.0; 4],
flip: None,
};
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
let sh = Shape {
+12 -3
View File
@@ -82,9 +82,18 @@ fn is_cj_nonstarter(c: char) -> bool {
)
}
/// A character that continues a Latin-style word: a letter or digit that Unicode does not break
/// between, as opposed to CJK ideographs, kana, spaces, punctuation and symbols such as `&` or `×`
/// (which share the AL and AI line-break classes with letters).
fn is_word_char(c: char) -> bool {
use unicode_linebreak::BreakClass::{Alphabetic, Ambiguous, HebrewLetter, Numeric};
matches!(unicode_linebreak::break_property(c as u32), Alphabetic | Ambiguous | HebrewLetter | Numeric) && c.is_alphanumeric()
}
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/`, `\`, `?`, `!`, `}`
/// or `+` inside a word (measured in PowerPoint 2016 for Windows; before an ideograph or a space
/// the Unicode break stays), and Korean breaks only between words.
pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
@@ -113,7 +122,7 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>,
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
false
} else {
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
latin_break || !matches!(prev, '/' | '\\' | '?' | '!' | '}' | '+') || !is_word_char(next)
};
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)

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