Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor c32cdf7eca refactor(av1): split OBU parsing in decoder (S3776) 2026-10-25 19:05:31 -04:00
Dylan M. Taylor 9d23ff97b2 Merge pull request #1394 from storytold/chore/deps-safe-refresh
chore(deps): refresh serde_json, arboard, winresource, i_overlay
2026-10-25 19:00:58 -04:00
skylar 1bee1857d6 chore(deps): bump serde_json, arboard, winresource, i_overlay 2026-10-25 16:36:20 -06:00
Dylan M. Taylor 13ed8889da refactor(av1): use method path instead of closure (S1612) (#1269) 2026-10-25 18:18:36 -04:00
Dylan M. Taylor dbc5e164dd build: pin craft-update to craft-libs main (495e597) (#1388)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:39 -04:00
Dylan M. TaylorandCodebuff 51eef9927f build: pin craft-update to craft-libs main (495e597) (#1386)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:32 -04:00
Dylan M. Taylor 222ef368f4 Merge pull request #1273 from storytold/b-h264hevc-4
refactor(hevc): Sonar S3776 slicedec helpers
2026-10-25 16:36:14 -04:00
Dylan M. Taylor d318583867 fix(render): optimize table styles, chart lookups, line trimming, and canvas limits (#1288)
* fix(render): optimize table styles, chart lookups, line trimming, and canvas limits

* fix(render): give outer-shadow passes their own traversal budget

A shadowed group's silhouette pass shared the 100,000-visit limit with normal drawing, so a group with many children could use it up before anything visible was drawn.

* perf(render): resolve a table's style once per draw
2026-10-25 16:01:43 -04:00
Dylan M. Taylor d4945d0443 fix(codecs): harden media decoders against resource amplification and unbound state (#1285)
* fix(codecs): harden media decoders against resource amplification and unbound state

- Ogg recovery (Finding 29): Reuse capture search window buffer and bound
  cumulative recovery work to prevent quadratic re-scanning.
- Opus packet concealment (Finding 40): Skip empty stored Opus packets
  instead of triggering packet-loss concealment audio.
- Opus decoded PCM budget (Finding 63): Enforce aggregate MAX_PCM_SAMPLES
  limit before expanding planar audio samples.
- H.264 DPB reference eviction (Finding 56): Validate MMCO 6 against
  max_long_term_frame_idx and evict oldest entries when capacity is reached.
- HEVC missing reference frames (Finding 57): Share gray replacement frames
  and bound missing references to DPB buffering limits.
- AV1 show_existing_frame budget (Finding 69): Cap frame outputs per chunk
  to 32 to prevent output batch memory explosion.
- AV1 nonuniform tile bounds (Finding 89): Enforce MAX_TILE_COLS and
  MAX_TILE_ROWS limits on nonuniform tile layouts.

* fix(codecs): refine show_existing_frame limit, PCM capacity check, and buffered Ogg search
2026-10-25 15:59:10 -04:00
Dylan M. Taylor b58df1095d build: pin craft-update to craft-libs main (db19c37) (#1383)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:34:20 -04:00
Dylan M. Taylor b70a6fda35 feat: startup update prompt modal and direct self-updating (#1377)
* feat: startup update prompt modal and direct self-updating

Ref: storytold/craft-libs#319

* build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:48:33 -04:00
Brandon ThomasandClaude Opus 5.5 5d9c3818a5 Tests: the Slide Size preset test opens the dialog's own combo box (egui 0.37 exposes the ribbon's too)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:49:12 -04:00
Brandon ThomasandClaude Opus 5.5 31cf44c2ff Integration: the merged PRs import what they use (#1270) and use egui 0.37's wheel source and media clock (#1350)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:45:19 -04:00
Brandon ThomasandClaude Opus 5.5 d20b08e3af Tests: drop the units test's PNG debug hook; the PDF link test's doc says only web and mail links export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 4504495e2b Tests: one blank line between the merged engine tests (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:43:24 -04:00
Brandon Thomas 5e9043afa6 Merge pull request #1336
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-25 13:43:07 -04:00
Brandon Thomas f1bb876e28 Merge pull request #1334
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-25 13:42:53 -04:00
Brandon Thomas 5648c4d7ec Merge pull request #1189
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-25 13:42:45 -04:00
Brandon Thomas d85e397be4 Merge pull request #1291
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-25 13:42:45 -04:00
Brandon Thomas 0e78adc675 Merge pull request #1259
# Conflicts:
#	crates/engine/src/cmd/richclip.rs
2026-10-25 13:42:29 -04:00
Brandon Thomas 556048436e Merge pull request #1221 2026-10-25 13:42:06 -04:00
Brandon Thomas 637bbcdf51 Merge pull request #1118 2026-10-25 13:42:06 -04:00
Brandon Thomas b6c1fe628c Merge pull request #1109 2026-10-25 13:42:06 -04:00
Brandon Thomas ad664b2cdd Merge pull request #1107 2026-10-25 13:42:06 -04:00
Brandon Thomas 8b2c0f456a Merge pull request #1294 2026-10-25 13:42:05 -04:00
Brandon Thomas ea4001a26e Merge pull request #1256 2026-10-25 13:42:05 -04:00
Brandon Thomas 4268cab027 Merge pull request #1246 2026-10-25 13:42:05 -04:00
Brandon Thomas c8a13561b3 Merge pull request #1331 2026-10-25 13:42:05 -04:00
Brandon Thomas 90f723918b Merge pull request #1319 2026-10-25 13:42:05 -04:00
Brandon Thomas 5f5f341d27 Merge pull request #1317 2026-10-25 13:42:05 -04:00
Brandon Thomas d217820576 Merge pull request #1315 2026-10-25 13:42:05 -04:00
Brandon Thomas d6d30bbf20 Merge pull request #1313 2026-10-25 13:42:05 -04:00
Brandon Thomas 7a9786b355 Merge pull request #1311 2026-10-25 13:42:05 -04:00
Brandon Thomas 5e2f1993c3 Merge pull request #1190 2026-10-25 13:42:05 -04:00
Brandon Thomas 24905e7057 Merge pull request #1358 2026-10-25 13:42:05 -04:00
Brandon Thomas 3c4d6e12de Merge pull request #1354 2026-10-25 13:42:05 -04:00
Brandon Thomas 7d6445e060 Merge pull request #1353 2026-10-25 13:42:05 -04:00
Brandon Thomas b21d71e648 Merge pull request #1352 2026-10-25 13:42:05 -04:00
Brandon Thomas ec274ec2fe Merge pull request #1351 2026-10-25 13:42:04 -04:00
Brandon Thomas 178edac4ac Merge pull request #1329 2026-10-25 13:42:04 -04:00
Brandon Thomas d0411d0332 Merge pull request #1304 2026-10-25 13:42:04 -04:00
Brandon Thomas f034901719 Merge pull request #1303 2026-10-25 13:42:04 -04:00
Brandon Thomas 547807c58d Merge pull request #1302 2026-10-25 13:42:04 -04:00
Brandon Thomas 5ed7844cb9 Merge pull request #1297 2026-10-25 13:42:04 -04:00
Brandon Thomas f9b564cd00 Merge pull request #1284 2026-10-25 13:42:04 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 7d4c49051c refactor: explicit imports in engine/anim/geom/model/render/text/pdf/ogg modules (SonarCloud S2208) (#1270)
* refactor: explicit imports instead of glob imports in command and test modules (SonarCloud S2208)

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

* fix: geom extra imports

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

* fix: drop unused imports

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

* fix: restore explicit imports in test modules (SonarCloud S2208)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 13:18:05 -04:00
skylar taylor-barrick 14f40c408c Migrate DeckCraft to egui 0.37 (#1350)
* egui037-20261022: migrate DeckCraft to egui 0.37

Preserve current-main document and PDF behavior alongside the reviewed input, capture, font, ribbon and notice-packaging migration.

Validation: cargo xtask ci passed all eight stages, with 1,877 workspace tests and three doctests passed, six tests ignored, strict native lint and 23 WASM crates checked. Native GUI, platform and performance qualification remains separate.

* E21: restore Latin coverage and enforce required font inputs

* Fix migration review findings in font validation and UI controls

* M-egui037: document UI lifecycle and regression contracts
2026-10-25 10:48:52 -04:00
FaabyyandClaude 52d0fcd6c7 Reuse Slides: insert into the presentation and after the slide the picker was opened for (#1103)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:23 +00:00
FaabyyandClaude 121441a079 Slides: the range anchor is held by slide id, so moving, inserting or deleting slides can't shift it (#1105)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:18 +00:00
FaabyyandClaude abdf5f337b Slides: a second Shift-click extends from the same anchor; the range clears cells and returns to the slides; a bad from is an error (#1105)
Review on #1107: read `from` with opt_usize_param, clear `sel.cells` and set
`target = Slides` as `slide.go` did, keep the anchor of the first Shift-click.
A kittest drives the Shift and Ctrl+Shift clicks in the slide sorter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:42 +00:00
FaabyyandClaude 95a8f90d1a Reuse Slides: insert from the bytes that were listed; a failed insert keeps the dialog open (#1103)
Review on #1109: the file is read once when the list is made and the same bytes
are inserted, so a file changed meanwhile can't swap the slides; `reuse_slides`
returns whether it inserted and the dialog closes only then. A kittest drives the
dialog.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:24 +00:00
FaabyyandClaude a378c78c25 Roadmap: record sel-range-1105
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:24 +00:00
FaabyyandClaude 29bd07e1b8 Slides: Ctrl+Shift+click selects the range, also in the slide sorter (#1105)
New slide.selectRange {to, from?, add?} selects every slide between the current one (or
`from`) and `to`. Shift+click in the slide list and the slide sorter selects that range;
Ctrl+Shift+click adds it to the slides already selected. The sorter had no range selection
before, and in the list Ctrl+Shift+click used to add only the clicked slide.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:48:27 +00:00
NetterSilverandClaude Haiku 5.5 6523a49fb7 Reject invalid layout index in master.deleteLayout and renameLayout
Found while testing crates/engine/src/cmd/design.rs:1353 (layout_target), used by delete_layout at :1368 and rename_layout at :1356

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:16:09 +00:00
NetterSilverandClaude Haiku 5.5 8a730912c2 Keep auto-advance when transition.timing gets an invalid after
Found while testing crates/engine/src/cmd/transition.rs:112-115 (timing)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 07:02:08 +00:00
NetterSilverandClaude Haiku 5.5 ea72d9d712 Fix HTML paste panic on bare '&' before multi-byte text
Found while testing crates/engine/src/cmd/richclip.rs:307-308 (decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:35:45 +00:00
NetterSilverandClaude Haiku 5.5 6a24586412 Resolve OPC relationship targets that use backslash separators
Found while testing crates/pptx/src/opc.rs:295-309 (resolve, used by rels_of)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:29:31 +00:00
NetterSilverandClaude Haiku 5.5 9c985e6a6a Ignore signed \'hh hex escapes in RTF outline import
Found while testing crates/format/src/rtf.rs:226 (rtf_to_outline)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:35:40 +01:00
Marco Arnulfo ab43e4b8f2 Units: Slide Size keeps each field's exact size in points, so a preset or Portrait gives the exact slide size (#1326) 2026-10-24 23:18:01 +02:00
Marco Arnulfo a7ba879a3c Pasted shapes are renamed only when the name is already taken on the slide; new shapes skip names in use (fixes #1290) 2026-10-24 23:10:35 +02:00
Marco Arnulfo d384bbc496 Units: Preferences chooses inches or centimetres for the Format pane, the ribbon's size boxes, the Slide Size dialog and the ruler (#1326) 2026-10-24 23:02:45 +02:00
Marco Arnulfo 98ce1b35be Pasted shapes get the next automatic name instead of keeping the original's (fixes #1290) 2026-10-24 22:52:52 +02:00
Marco Arnulfo 2e54272415 Show: Ctrl+wheel zooms the slide in and out around the pointer (#1286)
Cmd+wheel on a Mac. Four notches double the zoom, up to 4x; zooming back to the whole slide leaves the zoom. The plain wheel still turns the page, and the trackpad pinch is still not a show gesture.
2026-10-24 21:56:17 +02:00
NetterSilverandClaude Haiku 5.5 8145cc1bac Treat curly apostrophe as ASCII in spell check
Found while testing crates/engine/src/spell.rs:693 (paragraph_words) and :353 (Dictionary::check)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 19:24:20 +00:00
Marco Arnulfo 9341ae0616 Shapes: Arrow: Notched Right cuts its notch as deep as presetShapeDefinitions give it, parallel to the head 2026-10-24 19:26:42 +02:00
Marco Arnulfo 9e9e13fc2c Files: saving as Outline/RTF keeps every paragraph of a slide title 2026-10-24 19:11:59 +02:00
Marco Arnulfo 1525dae1bd Files: opening an RTF outline keeps non-breaking hyphens 2026-10-24 19:08:05 +02:00
Marco Arnulfo 53bdcbafef Files: opening a Word RTF outline skips the list bullets and keeps each item's indent 2026-10-24 19:04:10 +02:00
Marco Arnulfo 9830b57fbe Shapes: a speech bubble's wedge mirrors with its tip, at 7..10 twelfths on the far side as presetShapeDefinitions give it 2026-10-24 18:50:57 +02:00
Dominik Strebel 8236a9441b docs(roadmap): status line names the 2026-10-24 progress-log entry 2026-10-24 17:16:58 +02:00
NetterSilverandClaude Haiku 5.5 61b3108be7 Reject invalid index in animation remove, move, timing and options
Found while testing crates/engine/src/cmd/animation.rs:232-246 (remove); same pattern in each_target :265 and move_anim :250

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:06:43 +02:00
Dominik Strebel c15e260794 fix(render): an emphasis tint recolours the fill behind a picture's and an object preview's transparent pixels 2026-10-24 16:48:24 +02:00
NetterSilverandClaude Haiku 5.5 744c01f66f Match style as a whole attribute name in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:230-242 (attr, used by tag_style)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:35:42 +00:00
NetterSilverandClaude Haiku 5.5 35f240e453 Return BadParams for overflowing start in review.spellingChange
Found while testing crates/engine/src/cmd/review.rs:424 (spelling_change)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:53:26 +00:00
KataccandClaude Opus 5.5 8bc0afaff5 Test the Language dialog's default with nothing selected
With no text or shape selected, Review › Language must apply to the
whole presentation without choosing it, and Selected text must be
disabled. The existing test still covers switching to Whole
presentation while text is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 66e369de26 Doc comments for the language functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 291725c78e format.languageAll: also mark each text body's own list style
A layout placeholder's a:lstStyle can carry lang/noProof, and
ed::format_all only touched runs and end marks, so such a placeholder
kept its old language. Existing list style levels of shape, table cell
and notes bodies now get the edit too; bodies without one get none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 507fdc3767 Contributors: credit Katacc by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:53 +03:00
KataccandClaude Opus 5.5 2d3997d2e3 Review: proofing language for the whole presentation
Review › Language gets "Apply to: Selected text / Whole presentation".
The new format.languageAll command sets lang/noProof on every run in
the deck (slides, groups, table cells, notes, masters, layouts) and on
the master and default text styles, so new text inherits it. It needs
no selection and is one undo step; format.language is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:35 +03:00
Dominik Strebel a8f1573fba fix(pptx): an object saved as its preview picture keeps the fallback picture's fill 2026-10-24 14:29:30 +02:00
NetterSilverandClaude Haiku 5.5 53eda1e8df Reject multiple leading '#' in Rgba::from_hex
Found while testing crates/color/src/lib.rs:37

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:21:11 +02:00
Dominik StrebelandClaude Opus 5.5 aece097096 Render: a picture's fill shows behind its transparent pixels
PowerPoint draws a picture's own fill (p:pic/p:spPr) behind the picture, so
it shows through its transparent pixels: a synthetic deck with one
half-transparent PNG three times (no fill, an orange fill, a yellow fill
with a blue outline), exported as PDF, shows the orange and yellow boxes
behind the image. DeckCraft drew the outline but never the fill.

Old equation objects use this: an OLE object (p:oleObj, Equation.3) has a
fallback picture whose spPr gives it a coloured box behind the equation's
transparent metafile. The reader took only the picture's image as the
object's preview and dropped its fill and line.

- The shape-fill loop moves into Renderer::fill_geometry; a picture and an
  embedded object's preview paint their own explicit fill with it before
  the image (a style fill alone still draws nothing, as before).
- An embedded object without a fill or line of its own takes them from
  its fallback picture's spPr; its frame keeps its own position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:43 +02:00
oracola91 920925abae PDF export keeps only the links the slide show opens
A denylist can't keep up with OS protocol handlers: ms-msdt:, ms-word:
with a share path, ldap: and file:///%5C%5Chost/share still went into
exported PDFs. PDF export now writes a URL link only when safe_url
allows it, the check the slide show already uses on a click: http,
https and mailto. Links between slides are unchanged, and any other
link's text stays on the page without the link.

safe_url moves from deckcraft-anim to deckcraft-model, which both
crates use; deckcraft_anim::safe_url stays as a re-export. Local file:
and relative links are left out for now, as asked in review.
2026-10-24 13:31:06 +03:00
oracola91 274b626007 Test: every link of the unsafe-link test sits on the slide
The last links started below the 540 pt slide. They were still
exported, as zero-height annotations at the page edge, so the test
worked, but only because of that. With all 16 links on the slide the
test no longer depends on how off-page links are handled.
2026-10-24 13:27:00 +03:00
oracola91 afb52aeef3 PDF export also drops search: and search-ms: links
Windows Search's search: and search-ms: links can name a folder on
another machine through crumb=location:, and opening one connects to
that host and sends the user's NTLM credentials. They are now left out
of the PDF like the other network-share links, and the test covers both.
2026-10-24 13:27:00 +03:00
oracola91 8c4907a626 PDF export also drops smb: links
An smb://host/share link is a path to another machine too: opening it
connects to that host (macOS and Linux file managers open these links).
It is now left out of the PDF like the other network-share links, and
the test covers it.
2026-10-24 13:27:00 +03:00
oracola91 8fd1c1716f PDF export drops javascript:, vbscript:, data: and network-share links
A slide hyperlink from an untrusted deck became a PDF link whatever it
pointed at. Script and data links, and links to another machine (UNC
\\host\share, //host/share, file://host/...), are now left out of the
PDF; the text stays. On Windows, opening a network path sends the
user's NTLM credentials to that host.

Web, mail, local file: and relative links are kept. The scheme is read
the way a browser reads it: control characters dropped, leading spaces
trimmed, any case. The slide show already refuses these links
(safe_url), and cadcraft's PDF export drops script and data links.
2026-10-24 13:27:00 +03:00
NetterSilverandClaude Haiku 5.5 fb778030c9 Reject invalid shape ids in ids instead of using the selection
Found while testing crates/engine/src/cmd/mod.rs:ids_param / targets (~line 335-350)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 10:13:35 +00:00
NetterSilverandClaude Haiku 5.5 ef6819b6fd Reject '+' sign in OPC target percent-decoding
Found while testing crates/pptx/src/opc.rs:263-277 (percent_decode, used by resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 09:31:47 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 63a2e099a6 refactor(hevc): split slicedec functions (S3776)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:48:55 +00:00
SusieeTheFemboyandGLM-5.3 238b34be00 Media: a video with an unsupported codec says only its audio plays (#1237)
insert.video answered playable=true with no warning for a clip whose video
codec has no decoder (a VP8 + Vorbis WebM, say): its audio track plays, but
the slide shows an unexplained black box, which reads as "black with no
audio". The insert now warns that the video can't be played yet and only
the audio plays.

The missing audio itself is not reproducible: through the real insert +
play path the Vorbis track of a VP8 WebM decodes and sounds, on main and on
0.10.0 (the reporter's build). A regression test drives that whole path, on
a new synthesised clip-vp8.webm fixture.

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 10:41:30 +02:00
NetterSilverandClaude Haiku 5.5 ee248bb5cb Decode numeric tab entities in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:304 (numeric_char, used by decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 07:18:46 +00:00
NetterSilverandClaude Sonnet 5.5 866f349fbf fix: centre linear trendline sums so offset x ranges still fit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 23:31:52 +00:00
NetterSilverandClaude Sonnet 5.5 9679c6e16f fix: Name registered table commands in errors and reject invalid cell params
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:13:55 +02:00
SusieeTheFemboyandClaude Opus 5.5 2bc30fbd9a H.264: assert the r clamp's endpoints directly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:42:20 +02:00
SusieeTheFemboyandGLM-5.3 57acc28df3 H.264: the inverse transforms clamp coefficients to the spec r range
The dequantisation wraps by design (huge_levels_dequantise_without_
overflow_panics), so idct4_add and idct8_add receive any i32 coefficient,
and their plain-i32 butterflies panicked a debug build with "attempt to
add with overflow" on extreme values. A dequantised r outside
-2^15..2^15-1 leaves the transforms' output unspecified (ITU-T H.264
8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
bit-exact for every conforming stream — instead of panicking.

Debug-only (release wraps), memory-safe either way; the sibling SoundCraft
decoder fixed the same class (its audit's H.264 arithmetic findings).
Extends the grouped robustness audit's codec-overflow list (#1183), which
covers the POC/MMCO/latency sites but not these transforms.

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 00:34:06 +02:00
NetterSilverandClaude Haiku 5.5 c0128a0eb8 Use scale-relative degeneracy check in linear trendline fit
Found while testing crates/model/src/trendline.rs:281-313 (linreg)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:21:18 +02:00
NetterSilverandClaude Haiku 5.5 0ce545fcb8 Reject invalid row/col index in table row and column commands
Found while testing crates/engine/src/cmd/table.rs:234-238 (at), via crates/engine/src/cmd/mod.rs:318 (usize_param)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:46:51 +02:00
145 changed files with 14808 additions and 3123 deletions
+34 -1
View File
@@ -17,12 +17,45 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
# Keep this pin identical to gate, freebsd.yml and release.yml.
font-pin:
runs-on: ubuntu-latest
env:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Font pin gate
env:
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
# a proposed future SHA; update it together with all three workflow pins.
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
run: |
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
if [ -f "$manifest" ]; then
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
else
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
exit 1
fi
gate:
needs: font-pin
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
libgl1-mesa-dev
+5 -1
View File
@@ -27,4 +27,8 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
# qualify the required Chinese input after the pin advances; that gate stays explicitly
# pending in docs/egui-037-migration-plan.md section 5.1.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -1
View File
@@ -24,7 +24,10 @@ jobs:
with:
app-name: deckcraft
app-title: DeckCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+21 -5
View File
@@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings.
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / libvpx-vp9 / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
@@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings.
| Asset | Author | Source | Licence | Used for |
|---|---|---|---|---|
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
@@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`:
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
provides one) and BIZ UDPGothic Regular.
## PDF writer colour profiles
@@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
### Optional native Chinese fallback
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
| Family | Author | Source | Licence | Use |
|---|---|---|---|---|
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
Generated
+714 -331
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -61,15 +61,17 @@ 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 = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", 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 = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = { version = "0.37", default-features = false }
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -104,3 +106,18 @@ debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", 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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+24 -1
View File
@@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md.
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
not in the web payload.
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
@@ -80,3 +86,20 @@ Code dependencies
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+12
View File
@@ -3,9 +3,12 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **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-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-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)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; 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)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -136,7 +139,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---|
| 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) |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 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) |
@@ -146,14 +151,17 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
| 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
| 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-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 |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
@@ -286,12 +294,15 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
| 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
@@ -299,6 +310,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-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 |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
+14 -9
View File
@@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
const CANVAS_ID: &str = "deckcraft_canvas";
const LOADING_ID: &str = "deckcraft_loading";
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
/// build the sample or blank deck, and replace the loading element when launch finishes.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -36,6 +38,7 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
@@ -74,11 +77,17 @@ struct WebShell {
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
// Capture the document and slide now. The bytes arrive later, after the user may
// have switched slides.
self.app.logic(ctx);
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
/// and deliver the id, name, and bytes through the inbox.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
// fresh input, and capture the document/slide before the asynchronous read finishes.
for f in std::mem::take(&mut raw.dropped_files) {
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
@@ -97,11 +106,7 @@ impl eframe::App for WebShell {
}
});
}
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+1
View File
@@ -25,6 +25,7 @@ chrono = { workspace = true }
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
+14 -2
View File
@@ -42,8 +42,9 @@ impl eframe::App for App {
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward native raw input to the wrapped application once per pass.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -180,10 +181,19 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
/// Install logging, parse startup arguments and environment, launch the native app, and
/// propagate an eframe startup failure.
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 Err(e) = done {
eprintln!("DeckCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
@@ -255,8 +265,10 @@ fn main() -> eframe::Result {
"DeckCraft",
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {
app.session.recovery_dir = deckcraft_engine::recovery::default_dir();
+3
View File
@@ -62,3 +62,6 @@ sync_github_name = false
[people.GhostMitra]
sync_github_name = true
[people.Katacc]
sync_github_name = true
+2 -12
View File
@@ -29,18 +29,8 @@ pub enum ShowAction {
Exit,
}
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
/// opened on a click.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
// Which links the show opens on a click; PDF export writes the same ones.
pub use deckcraft_model::text::safe_url;
/// What a clicked link does in the show (`ShowState::follow`).
#[derive(Clone, Debug, PartialEq, Eq)]
+4 -1
View File
@@ -7,7 +7,10 @@ use deckcraft_model::{
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
};
use crate::*;
use crate::{
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
};
const W: f64 = 960.0;
const H: f64 = 540.0;
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use deckcraft_model::text::Action;
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
use crate::*;
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
fn shape(id: u32) -> Shape {
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
+80 -54
View File
@@ -405,49 +405,35 @@ impl Decoder {
r
}
/// Whether an OBU belongs to the selected operating point (spec 7.5: OBUs of other layers are dropped).
fn in_operating_point(&self, obu: &ObuUnit) -> bool {
if obu.obu_type != OBU_SEQUENCE_HEADER_T
&& obu.obu_type != OBU_TEMPORAL_DELIMITER_T
&& obu.ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> obu.temporal_id) & 1;
let in_s = (idc >> (obu.spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
return false;
}
}
true
}
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
let mut pos = 0;
while pos < data.len() {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
let obu = parse_obu_unit(data, pos)?;
pos = obu.end;
if !self.in_operating_point(&obu) {
continue;
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
let payload = &data[p..end];
pos = end;
if obu_type != OBU_SEQUENCE_HEADER_T
&& obu_type != OBU_TEMPORAL_DELIMITER_T
&& ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> temporal_id) & 1;
let in_s = (idc >> (spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
continue;
}
}
match obu_type {
let (payload, spatial_id) = (obu.payload, obu.spatial_id);
match obu.obu_type {
OBU_SEQUENCE_HEADER_T => {
let s = SequenceHeader::parse(payload)?;
self.seq = Some(s);
@@ -457,24 +443,11 @@ impl Decoder {
tu += 1;
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if self.seen_frame_header {
// frame_header_copy(): identical to the active header
if obu_type != OBU_FRAME_T {
continue;
if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
}
let mut r = BitReader::new(payload);
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
if let Some(o) = shown {
out.push((o, tu, spatial_id));
continue;
}
if obu_type == OBU_FRAME_T {
r.byte_align();
let rest = payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
if let Some(o) = self.tile_group(rest)? {
out.push((o, tu, spatial_id));
}
}
}
OBU_TILE_GROUP_T => {
@@ -489,6 +462,22 @@ impl Decoder {
Ok(())
}
/// A frame header or frame OBU: returns the picture it completes, if any.
fn frame_obu(&mut self, obu: &ObuUnit) -> Result<Option<Output>> {
// frame_header_copy(): identical to the active header
if self.seen_frame_header && obu.obu_type != OBU_FRAME_T {
return Ok(None);
}
let mut r = BitReader::new(obu.payload);
let shown = self.frame_header(&mut r, obu.temporal_id, obu.spatial_id)?;
if shown.is_some() || obu.obu_type != OBU_FRAME_T {
return Ok(shown);
}
r.byte_align();
let rest = obu.payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
self.tile_group(rest)
}
/// frame_header_obu( ): returns the output for show_existing_frame.
fn frame_header(&mut self, r: &mut BitReader, temporal_id: u32, spatial_id: u32) -> Result<Option<Output>> {
let seq = self.seq.clone().ok_or(Error::Invalid("frame before sequence header"))?;
@@ -1014,6 +1003,43 @@ mod tests {
}
}
/// One OBU: its header fields, payload and the position just past it.
struct ObuUnit<'a> {
obu_type: u32,
ext: bool,
temporal_id: u32,
spatial_id: u32,
payload: &'a [u8],
end: usize,
}
fn parse_obu_unit(data: &[u8], pos: usize) -> Result<ObuUnit<'_>> {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
Ok(ObuUnit { obu_type, ext, temporal_id, spatial_id, payload: &data[p..end], end })
}
#[cfg(test)]
mod guard_tests {
use super::*;
+6
View File
@@ -906,6 +906,9 @@ impl FrameHeader {
let mut widest = 0;
let mut start = 0;
while start < sb_cols {
if t.mi_col_starts.len() >= MAX_TILE_COLS {
return Err(Error::Invalid("tile_cols exceeds MAX_TILE_COLS"));
}
t.mi_col_starts.push(start << sb_shift);
let max_width = (sb_cols - start).min(max_tile_width_sb);
let size = r.ns(max_width)? + 1;
@@ -923,6 +926,9 @@ impl FrameHeader {
let max_tile_height_sb = (max_tile_area_sb / widest.max(1)).max(1);
let mut start = 0;
while start < sb_rows {
if t.mi_row_starts.len() >= MAX_TILE_ROWS {
return Err(Error::Invalid("tile_rows exceeds MAX_TILE_ROWS"));
}
t.mi_row_starts.push(start << sb_shift);
let max_height = (sb_rows - start).min(max_tile_height_sb);
let size = r.ns(max_height)? + 1;
+1 -1
View File
@@ -104,7 +104,7 @@ impl<const L: usize> Lane for [i32; L] {
}
#[inline(always)]
fn neg(self) -> Self {
self.map(|v| v.wrapping_neg())
self.map(i32::wrapping_neg)
}
#[inline(always)]
fn mul_round12(self, m: i32) -> Self {
+11 -1
View File
@@ -34,7 +34,8 @@ impl Rgba {
}
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
@@ -757,6 +758,15 @@ pub fn is_system_name(name: &str) -> bool {
mod tests {
use super::*;
#[test]
fn from_hex_rejects_multiple_hashes() {
assert_eq!(Rgba::from_hex("##FF0000"), None);
assert_eq!(Rgba::from_hex("####FF0000"), None);
assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128)));
}
#[test]
fn from_hex_rejects_signs() {
assert_eq!(Rgba::from_hex("+12345"), None);
+27 -4
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -230,7 +232,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation.remove")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?);
@@ -247,7 +249,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?;
let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?;
let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?;
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?);
@@ -263,7 +265,7 @@ fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?);
@@ -567,4 +569,25 @@ mod tests {
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
}
#[test]
fn invalid_index_is_an_error_not_the_selection() {
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();
s.select(|_, sel| {
sel.shapes = vec![deckcraft_model::ShapeId(id as u32)];
})
.unwrap();
for bad_index in [json!(-1), json!(1.5), json!("x")] {
for cmd in ["animation.remove", "animation.timing", "animation.options"] {
let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100}));
assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}");
}
let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0}));
assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}");
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
}
}
}
+4 -1
View File
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
use image::{GrayImage, Rgba, RgbaImage};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
use crate::{Result, Session};
/// The effects offered: `a14` element name and label.
+6 -1
View File
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
use crate::{Result, Session};
/// Larger pictures are refused rather than matted slowly (16 megapixels).
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::*;
use crate::Session;
use crate::cmd::base64_encode;
/// A red disc on a white-to-grey gradient.
fn disc() -> image::RgbaImage {
+1 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use crate::*;
use crate::Session;
fn deck() -> Session {
let mut s = Session::with_new();
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+27 -5
View File
@@ -10,7 +10,10 @@ use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builti
use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, always, bad, bool_or, bool_param, cmd, color_param, color_value, f64_param, has_doc, has_slide, ok, opt_usize_param,
rect_param, str_param, usize_param, with_param,
};
use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
@@ -1344,17 +1347,17 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn layout_target(s: &Session, p: &Value) -> Result<(usize, usize)> {
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.doc()?;
let (m, l) = match st.selection.target {
Target::Layout { master, layout } => (master, layout),
_ => (master_index(&st.doc, &st.selection), 0),
};
Ok((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l)))
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
}
fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p)?;
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
s.edit(|doc, _| {
let l =
@@ -1366,7 +1369,7 @@ fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p)?;
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
s.edit(|doc, sel| {
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
if doc.slides.iter().any(|x| x.layout == id) {
@@ -1619,6 +1622,25 @@ mod tests {
}
}
#[test]
fn layout_commands_reject_invalid_index() {
let mut s = Session::with_new();
s.execute("master.insertLayout", &json!({})).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
let n = count(&s);
let before = names(&s);
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
assert_eq!(count(&s), n);
assert_eq!(names(&s), before);
}
s.execute("master.deleteLayout", &json!({})).unwrap();
assert_eq!(count(&s), n - 1);
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
+15 -1
View File
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
richclip, str_param, targets, xfrm_of,
};
use crate::{HistoryEntry, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -318,7 +321,18 @@ 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);
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.
if taken.contains(&sh.name)
&& let Some((base, n)) = sh.name.rsplit_once(' ')
&& !n.is_empty()
&& n.bytes().all(|b| b.is_ascii_digit())
{
sh.name = super::insert::first_free_name(base, &taken);
}
taken.insert(sh.name.clone());
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
x.y += off;
+1 -1
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::*;
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+75 -5
View File
@@ -6,10 +6,15 @@ use deckcraft_model::resolve;
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::*;
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,
};
use crate::{Result, Session};
use deckcraft_model::ShapeId;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -60,6 +65,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
language
),
cmd!(
"format.languageAll",
"Language for All Text",
["Review", "Language"],
None,
"{lang?, noProof?: bool} (proofing language of every text in the presentation: slides, tables, notes, masters and layouts, and the default for new text; one undo step)",
has_doc,
language_all
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!(
"format.case",
@@ -406,16 +420,17 @@ fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
r.fill = Some(f);
})
}
fn language(s: &mut Session, p: &Value) -> Result<Value> {
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
let lang = str_param(p, "lang").map(str::to_string);
let no_proof = bool_param(p, "noProof");
if lang.is_none() && no_proof.is_none() {
return Err(bad("format.language", "give `lang` or `noProof`"));
return Err(bad(id, "give `lang` or `noProof`"));
}
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
return Err(bad("format.language", "`lang` is a language tag such as en-US"));
return Err(bad(id, "`lang` is a language tag such as en-US"));
}
format_run(s, &move |r| {
Ok(move |r: &mut RunProps| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
@@ -424,6 +439,61 @@ fn language(s: &mut Session, p: &Value) -> Result<Value> {
}
})
}
/// Review › Language on the selected text (or the selected shapes' text).
fn language(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.language", p)?;
format_run(s, &f)
}
/// Every run in the deck, and the text styles new text inherits from, in one language.
fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.languageAll", p)?;
/// Runs, end marks and the body's own list style levels (never creating one).
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
ed::format_all(t, f);
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
if depth > 64 {
return;
}
for sh in list {
if let Some(t) = &mut sh.text {
body(t, f);
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
_ => {}
}
}
}
/// A master or presentation text style's levels: what new text inherits.
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
// Level 1 always, so a deck without a default text style gets one.
if l.level(0).is_none() {
l.set(0, Default::default());
}
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
s.edit(|doc, _| {
for sl in &mut doc.slides {
let sl = Arc::make_mut(sl);
shapes(&mut sl.shapes, &f, 0);
body(&mut sl.notes, &f);
}
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
let m = Arc::make_mut(m);
shapes(&mut m.shapes, &f, 0);
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
}
styles(&mut doc.default_text_style, &f);
Ok(())
})?;
ok()
}
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
+1 -1
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::*;
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
use crate::{Result, Selection, Session};
pub fn specs() -> Vec<CommandSpec> {
+50 -25
View File
@@ -8,7 +8,10 @@ use deckcraft_model::text::{AutoFit, RunKind, TextBody};
use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, always, bad, base64_decode, base64_encode, bool_or, bool_param, cmd, color_value, edit_shapes, has_doc, has_selection,
has_slide, has_text_or_shapes, ok, rect_param, str_param, usize_param, xfrm_of,
};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -149,27 +152,38 @@ fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm {
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
}
/// The word an automatic shape name starts with ("Rectangle" in "Rectangle 3").
pub(crate) fn auto_name_base(shape: &Shape) -> String {
match &shape.kind {
ShapeKind::Picture { .. } => "Picture".to_string(),
ShapeKind::Table(_) => "Table".to_string(),
ShapeKind::Chart(_) => "Chart".to_string(),
ShapeKind::Media(m) if m.video => "Video".to_string(),
ShapeKind::Media(_) => "Audio".to_string(),
ShapeKind::Model3d { .. } => "3D Model".to_string(),
_ if shape.text_box => "TextBox".to_string(),
_ => shape
.geom
.preset_name()
.and_then(deckcraft_geom::preset::info)
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
.unwrap_or_else(|| "Shape".into()),
}
}
/// "{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()
}
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
s.edit(|doc, sel| {
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
if shape.name.is_empty() {
let base = match &shape.kind {
ShapeKind::Picture { .. } => "Picture".to_string(),
ShapeKind::Table(_) => "Table".to_string(),
ShapeKind::Chart(_) => "Chart".to_string(),
ShapeKind::Media(m) if m.video => "Video".to_string(),
ShapeKind::Media(_) => "Audio".to_string(),
ShapeKind::Model3d { .. } => "3D Model".to_string(),
_ if shape.text_box => "TextBox".to_string(),
_ => shape
.geom
.preset_name()
.and_then(deckcraft_geom::preset::info)
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
.unwrap_or_else(|| "Shape".into()),
};
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1;
shape.name = format!("{base} {n}");
let base = auto_name_base(&shape);
let taken = super::current_names(doc, sel);
shape.name = first_free_name(&base, &taken);
}
let id = shape.id;
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
@@ -1278,14 +1292,25 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
}
Err(_) => (0, 0, 0, false),
};
let warning = (!playable).then(|| match &info {
Ok(i) => {
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
}
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
});
let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable));
let warning = if !playable {
Some(match &info {
Ok(i) => {
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
}
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
})
} else if video && !decodable_video {
// A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame
// can ever show — say so instead of leaving an unexplained black box (#1237).
info.as_ref()
.ok()
.and_then(|i| i.video.as_ref())
.map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec))
} else {
None
};
let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None };
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or_else(|| {
+1 -1
View File
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+18 -2
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::ShapeId;
use serde_json::json;
use crate::cmd::base64_encode;
use crate::{MediaStatus, Session, UiRequest};
fn wav() -> Vec<u8> {
let rate = 8000u32;
@@ -286,4 +290,16 @@ mod tests {
assert_eq!(r["playable"], false);
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
}
#[test]
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
let mut s = Session::with_new();
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
}
}
+7 -2
View File
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::single::SingleFloatOverlay;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
use crate::{Result, Session};
/// Closed polygons (slide points) describing a filled area (non-zero winding).
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::{Area, area_of, area_size, letters_of, merge_areas};
use crate::Session;
use crate::cmd::base64_encode;
fn square(x: f64, y: f64, s: f64) -> Area {
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
+9
View File
@@ -355,6 +355,11 @@ pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
/// Targets: `ids` / `id` params, else the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
if let Some(a) = p.get("ids").and_then(Value::as_array)
&& let Some(v) = a.iter().find(|v| v.as_u64().and_then(|v| u32::try_from(v).ok()).is_none())
{
return Err(bad("shape", format!("`ids` has an invalid shape id: {v}")));
}
if let Some(ids) = ids_param(p, "ids")
&& !ids.is_empty()
{
@@ -510,6 +515,10 @@ 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()
}
/// The command palette runs a command with `{}`. True when the params doc names a value that
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
use deckcraft_model::{ColorRef, Geom, Line, Shape};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::style::{PictureAdjust, PictureMode};
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
+19 -2
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::Comment;
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
};
use crate::{Result, Session, Target, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -430,7 +432,7 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
let end = start + word.chars().count();
let end = start.checked_add(word.chars().count()).ok_or_else(|| bad("review.spellingChange", "bad `start`"))?;
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
let slide = {
let st = s.doc()?;
@@ -843,3 +845,18 @@ mod tests {
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
}
}
#[cfg(test)]
mod spelling_change_range_tests {
use crate::{EngineError, Session};
use serde_json::json;
#[test]
fn spelling_change_huge_start_is_bad_params() {
let mut s = Session::with_new();
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
let id = r["id"].clone();
let err = s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": u64::MAX, "word": "Hello", "to": "x"})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
}
+38 -3
View File
@@ -231,7 +231,11 @@ fn weight_bold(style: &str) -> Option<bool> {
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let at = lower.match_indices(key).find_map(|(at, _)| {
let named = lower[..at].ends_with(|c: char| c.is_ascii_whitespace());
let eq = tag.get(at + key.len()..)?.trim_start().starts_with('=');
(named && eq).then_some(at)
})?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
@@ -304,7 +308,10 @@ fn decode_text(raw: &str) -> String {
while let Some(at) = rest.find('&') {
out.push_str(&collapse_space(&rest[..at]));
rest = &rest[at..];
let search_window = rest.len().min(32);
let mut search_window = rest.len().min(32);
while !rest.is_char_boundary(search_window) {
search_window -= 1;
}
let Some(end) = rest[..search_window].find(';') else {
out.push('&');
rest = &rest[1..];
@@ -343,7 +350,7 @@ fn numeric_char(digits: &str) -> Option<char> {
return None;
}
let n = u32::from_str_radix(body, radix).ok()?;
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
char::from_u32(n).filter(|c| !c.is_control() || matches!(*c, '\n' | '\t'))
}
/// Do the HTML's words and the paste's plain text say the same thing?
@@ -361,6 +368,14 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn multibyte_after_ampersand_is_kept() {
let text = "é".repeat(20);
assert_eq!(decode_text(&format!("R&D {text}")), format!("R&D {text}"));
let cjk = "漢".repeat(20);
assert_eq!(decode_text(&format!("&{cjk}")), format!("&{cjk}"));
}
#[test]
fn signed_numeric_entities_are_kept_as_typed() {
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
@@ -368,6 +383,12 @@ mod tests {
assert_eq!(decode_text("a&#65;b&#x41;"), "aAbA");
}
#[test]
fn numeric_tab_entity_decodes_to_tab() {
assert_eq!(decode_text("a&#9;b"), "a\tb");
assert_eq!(decode_text("a&#x9;b"), "a\tb");
}
#[test]
fn google_docs_normal_weight_wrapper_is_not_bold() {
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
@@ -382,6 +403,20 @@ mod tests {
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn style_attribute_is_found_after_attributes_containing_style() {
for tag in [
"<span class=\"style1\" style=\"font-weight:bold\">x</span>",
"<span data-style=\"x\" style=\"font-weight:bold\">x</span>",
"<span class=\"mystyle\" style=\"font-weight:bold\">x</span>",
] {
let body = html_to_body(tag).unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{tag}");
}
let none = html_to_body("<span class=\"style\">x</span>").unwrap();
assert_eq!(none.paragraphs[0].runs[0].props.bold, None);
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
+1 -1
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{Shape, ShapeId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -2,8 +2,8 @@
use serde_json::{Value, json};
use crate::Session;
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::*;
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
fn deck() -> (Session, Vec<u32>) {
+6 -1
View File
@@ -8,7 +8,10 @@ use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop,
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -1519,6 +1522,8 @@ fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
mod tests {
#![allow(clippy::unwrap_used)]
use deckcraft_model::ShapeId;
use super::*;
use serde_json::json;
+146 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::text::TextBody;
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, has_doc, has_slide, ok, opt_usize_param, str_param, usize_param};
use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
@@ -28,6 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first),
cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last),
cmd!(noundo "slide.selectRange", "Select Slide Range", [], None, "{to: index, from?: index (default the current slide), add?: bool (keep the slides already selected)}", has_slide, select_range),
cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides),
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
@@ -303,6 +304,7 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.range_anchor = None;
sel.target = Target::Slides;
})?;
Ok(json!({"index": s.doc()?.selection.slide}))
@@ -368,6 +370,47 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
}
sel.shapes.clear();
sel.text = None;
sel.range_anchor = None;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
/// Shift-click in the slide list: select every slide from `from` to `to`. Without `from` the range
/// starts at the anchor of the previous Shift-click (so a second Shift-click extends from the same
/// slide, as in PowerPoint), else at the current slide.
/// With `add` the slides already selected stay selected (Ctrl+Shift-click).
fn select_range(s: &mut Session, p: &Value) -> Result<Value> {
let to = usize_param(p, "to").ok_or_else(|| bad("slide.selectRange", "missing `to`"))?;
let given = opt_usize_param(p, "from", "slide.selectRange")?;
let add = bool_or(p, "add", false);
let (from, count) = {
let st = s.doc()?;
let sel = &st.selection;
let current = st.doc.slides.get(sel.slide).map(|x| x.id);
let anchor = sel.range_anchor.filter(|(_, end)| Some(*end) == current).and_then(|(a, _)| st.doc.slide_index(a));
(given.or(anchor).unwrap_or(sel.slide), st.doc.slides.len())
};
if to >= count || from >= count {
return Err(bad("slide.selectRange", "no such slide"));
}
let (a, b) = (from.min(to), from.max(to));
s.select(|doc, sel| {
let range: Vec<SlideId> = doc.slides.iter().skip(a).take(b - a + 1).map(|x| x.id).collect();
if add {
for id in range {
if !sel.slides.contains(&id) {
sel.slides.push(id);
}
}
} else {
sel.slides = range;
}
sel.slide = to;
sel.range_anchor = doc.slides.get(from).zip(doc.slides.get(to)).map(|(a, b)| (a.id, b.id));
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.target = Target::Slides;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
@@ -697,4 +740,106 @@ mod tests {
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
}
fn deck_of(n: usize) -> Session {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < n {
s.execute("slide.new", &json!({})).expect("new slide");
}
s
}
fn selected_indices(s: &Session) -> Vec<usize> {
let st = s.doc().expect("doc");
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
v.sort_unstable();
v
}
#[test]
fn select_range_covers_every_slide_between() {
let mut s = deck_of(6);
s.execute("slide.go", &json!({"index": 1})).expect("go");
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
assert_eq!(selected_indices(&s), vec![1, 2, 3, 4]);
assert_eq!(s.doc().expect("doc").selection.slide, 4);
// Backwards works too, and replaces the earlier selection.
s.execute("slide.selectRange", &json!({"from": 5, "to": 3})).expect("range");
assert_eq!(selected_indices(&s), vec![3, 4, 5]);
}
#[test]
fn select_range_add_keeps_the_existing_selection() {
let mut s = deck_of(8);
s.execute("slide.selectSlides", &json!({"indices": [0, 1]})).expect("select");
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectSlides", &json!({"indices": [4]})).expect("select");
s.execute("slide.selectRange", &json!({"to": 6, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
s.execute("slide.selectSlides", &json!({"indices": [0]})).expect("select");
s.execute("slide.selectRange", &json!({"from": 3, "to": 5, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 3, 4, 5]);
}
#[test]
fn select_range_rejects_out_of_range_slides() {
let mut s = deck_of(3);
assert!(s.execute("slide.selectRange", &json!({"to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": 9, "to": 1})).is_err());
assert!(s.execute("slide.selectRange", &json!({})).is_err());
}
#[test]
fn select_range_rejects_an_invalid_from_instead_of_using_the_current_slide() {
let mut s = deck_of(4);
s.execute("slide.go", &json!({"index": 2})).expect("go");
assert!(s.execute("slide.selectRange", &json!({"from": -1, "to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": "x", "to": 3})).is_err());
assert_eq!(selected_indices(&s), vec![2]);
}
#[test]
fn a_second_range_extends_from_the_original_anchor() {
let mut s = deck_of(8);
s.execute("slide.go", &json!({"index": 2})).expect("go");
s.execute("slide.selectRange", &json!({"to": 5})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5]);
// Shift-click further on: still anchored at 2, not at 5.
s.execute("slide.selectRange", &json!({"to": 7})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5, 6, 7]);
// Shift-click back across the anchor.
s.execute("slide.selectRange", &json!({"to": 0})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 1, 2]);
// A plain click on another slide starts a new anchor.
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectRange", &json!({"to": 6})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
}
#[test]
fn moving_a_slide_drops_the_range_anchor() {
let mut s = deck_of(6);
s.execute("slide.go", &json!({"index": 0})).expect("go");
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
// Slide 0 moves to index 2 and stays current; index 0 now holds another slide, so the
// next Shift-click starts at the moved slide instead of at index 0.
s.execute("slide.move", &json!({"from": 0, "to": 2})).expect("move");
assert_eq!(s.doc().expect("doc").selection.slide, 2);
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4]);
}
#[test]
fn select_range_clears_a_table_cell_selection_and_returns_to_the_slides() {
let mut s = deck_of(3);
s.select(|_, sel| {
sel.cells = Some((deckcraft_model::ShapeId(1), (0, 0), (1, 1)));
sel.target = crate::Target::Master { master: 0 };
})
.expect("cells");
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
let sel = &s.doc().expect("doc").selection;
assert!(sel.cells.is_none());
assert_eq!(sel.target, crate::Target::Slides);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use deckcraft_model::{
};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, current_shapes, has_selection, shape_of, str_param, targets, xfrm_of};
use crate::{EngineError, Result, Session};
/// Deepest outline level the Text Pane allows.
+58 -24
View File
@@ -4,28 +4,49 @@ use deckcraft_geom::{Affine, Point, Rect};
use deckcraft_model::{Shape, ShapeKind, Table};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, bad, bool_or, bool_param, cmd, color_param, edit_shapes, f64_param, has_selection, has_slide, id_param, ok, opt_usize_param,
str_param, table_drawn_heights, xfrm_of,
};
use crate::{DocState, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("table.insertRowAbove", "Insert Above", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "rowAbove"
s,
p,
"rowAbove",
"table.insertRowAbove"
)),
cmd!("table.insertRowBelow", "Insert Below", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "rowBelow"
s,
p,
"rowBelow",
"table.insertRowBelow"
)),
cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "colLeft"
s,
p,
"colLeft",
"table.insertColumnLeft"
)),
cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "colRight"
s,
p,
"colRight",
"table.insertColumnRight"
)),
cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "delRow"
s,
p,
"delRow",
"table.deleteRow"
)),
cmd!("table.deleteColumn", "Delete Columns", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "delCol"
s,
p,
"delCol",
"table.deleteColumn"
)),
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
cmd!(
@@ -225,16 +246,26 @@ impl TableFrame {
}
}
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
/// A `[r, c]` cell param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid cell, so a bad cell never acts on the selection.
fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>> {
let v = match p.get(key) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
match (idx(0), idx(1)) {
(Some(r), Some(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
/// The table's row/col to act on: params, the edited cell, or the selected cells.
fn at(s: &Session, p: &Value) -> (usize, usize) {
/// A present but invalid `row`/`col` is an error; only an absent one falls back.
fn at(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.active();
let cell = st.and_then(|d| d.selection.text.as_ref().and_then(|t| t.cell)).or_else(|| st.and_then(|d| d.selection.cells.map(|c| c.1)));
(usize_param(p, "row").or(cell.map(|c| c.0)).unwrap_or(0), usize_param(p, "col").or(cell.map(|c| c.1)).unwrap_or(0))
Ok((opt_usize_param(p, "row", cmd)?.or(cell.map(|c| c.0)).unwrap_or(0), opt_usize_param(p, "col", cmd)?.or(cell.map(|c| c.1)).unwrap_or(0)))
}
fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result<Value> {
@@ -282,10 +313,10 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
Ok(r)
}
fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let (r, c) = at(s, p);
fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result<Value> {
let (r, c) = at(s, p, name)?;
let op = op.to_string();
with_table(s, p, "table", move |t| {
with_table(s, p, name, move |t| {
match op.as_str() {
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r.saturating_add(1)),
@@ -293,12 +324,12 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
"colRight" => t.insert_col(c.saturating_add(1)),
"delRow" => {
if !t.delete_row(r) {
return Err(bad("table.deleteRow", "can't delete the last row"));
return Err(bad(name, "can't delete the last row"));
}
}
_ => {
if !t.delete_col(c) {
return Err(bad("table.deleteColumn", "can't delete the last column"));
return Err(bad(name, "can't delete the last column"));
}
}
}
@@ -308,8 +339,8 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let sel = s.doc()?.selection.cells;
let from = cell_param(p, "from").or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
let to = cell_param(p, "to").or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
let from = cell_param(p, "from", "table.merge")?.or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
let to = cell_param(p, "to", "table.merge")?.or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
with_table(
s,
p,
@@ -319,7 +350,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let cell = cell_param(p, "cell").unwrap_or_else(|| at(s, p));
let cell = match cell_param(p, "cell", "table.split")? {
Some(c) => c,
None => at(s, p, "table.split")?,
};
with_table(s, p, "table.split", move |t| if t.split(cell.0, cell.1) { Ok(()) } else { Err(bad("table.split", "that cell isn't merged")) })
}
@@ -533,7 +567,7 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
}
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
let (_, c) = at(s, p);
let (_, c) = at(s, p, "table.columnWidth")?;
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
let keep_total = bool_or(p, "keepTotal", false);
with_table(s, p, "table.columnWidth", move |t| {
@@ -562,7 +596,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
}
fn row_height(s: &mut Session, p: &Value) -> Result<Value> {
let (r, _) = at(s, p);
let (r, _) = at(s, p, "table.rowHeight")?;
let h = f64_param(p, "height").ok_or_else(|| bad("table.rowHeight", "missing `height`"))?.clamp(4.0, 4000.0);
with_table(s, p, "table.rowHeight", move |t| {
if let Some(x) = t.rows.get_mut(r) {
@@ -577,8 +611,8 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
Some(i) => i,
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?,
};
let from = cell_param(p, "from").unwrap_or((0, 0));
let to = cell_param(p, "to").unwrap_or(from);
let from = cell_param(p, "from", "table.selectCells")?.unwrap_or((0, 0));
let to = cell_param(p, "to", "table.selectCells")?.unwrap_or(from);
s.select(|_, sel| {
sel.cells = Some((id, from, to));
sel.shapes = vec![id];
+559 -3
View File
@@ -9,9 +9,14 @@ use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind,
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{DocState, Result, Selection, Session, Target, TextSel};
use super::{
CommandSpec, NO_TEXT_POINT, SELECT_TEXT_OR_SHAPE, always, bad, bool_or, cmd, edit_shapes, has_doc, has_slide, has_text, id_param, ok, shape_of,
str_param, xfrm_of,
};
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands
/// with their parameter docs and enabled predicates.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
@@ -66,6 +71,30 @@ pub fn specs() -> Vec<CommandSpec> {
has_text,
delete
),
// An input method asks for text around the caret to be removed (macOS press-and-hold,
// dictation, Wayland text-input). Internal: the keyboard and control protocol keep the
// existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's
// `Ime::DeleteSurrounding`), otherwise characters, both in the logical text
// `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a
// range may cross paragraphs. The selection and pre-edit are kept; byte ranges round
// inward to whole scalars. A deletion while composing stays in the composition's single
// undo step.
//
// `undoable: false` is deliberate even though a non-composing deletion is undoable: the
// handler pushes its own history entry from a snapshot taken before the composition is
// replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays
// off for the same reason an input-method edit is not a replayable document command.
CommandSpec {
id: "text.deleteSurrounding",
label: "Delete Surrounding Text",
menu: &[],
shortcut: None,
params: "{before, after, bytes?: bool}",
enabled: has_text,
run: delete_surrounding,
journal: false,
undoable: false,
},
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
@@ -111,6 +140,240 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
Some(sh.text.get_or_insert_with(TextBody::default))
}
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
///
/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the
/// engine answer without copying a whole body or document.
pub const IME_SURROUNDING_MAX_BYTES: usize = 4000;
/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8
/// byte offsets. The current input-method composition (pre-edit) is removed from both the text
/// and the offsets.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
pub text: String,
pub cursor: usize,
pub anchor: usize,
}
/// How a soft line break reads in the IME view: a newline, like a paragraph break.
///
/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would
/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines
/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change.
fn ime_char(ch: char) -> char {
if ch == '\u{b}' { '\n' } else { ch }
}
/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`.
fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
p.runs
.iter()
.flat_map(|run| run.content().chars())
.enumerate()
.filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))))
}
/// The same characters, newest first, without walking the paragraph from its start.
fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
let mut starts = Vec::with_capacity(p.runs.len());
let mut at = 0usize;
for r in &p.runs {
let n = r.char_len();
starts.push((at, n));
at += n;
}
p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| {
let content = r.content();
content.chars().rev().enumerate().filter_map(move |(k, ch)| {
let i = start + n - 1 - k;
(!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))
})
})
}
/// The composition's `(paragraph, start, end)` character range, when it is inside the body.
fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> {
let c = c?;
let para = body.paragraphs.get(c.start.0)?;
let start = c.start.1.min(para.char_len());
let end = start.saturating_add(c.len).min(para.char_len());
(start < end).then_some((c.start.0, start, end))
}
/// Return the composition character range only when its paragraph index equals `pi`.
fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> {
comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e))
}
/// Clip a model position to the body. An empty body only has position `(0, 0)`.
fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> {
if body.paragraphs.is_empty() {
return (pos == (0, 0)).then_some((0, 0));
}
body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len())))
}
/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
/// characters), stopping at the end of the body. Returns the position reached and the characters
/// and bytes consumed.
///
/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks
/// that paragraph's runs from their start: runs do not cache character lengths, and a position is
/// a character index. A megabyte paragraph therefore costs time proportional to the caret's
/// character offset, even though the copied surrounding text stays within `limit`.
fn walk_forward(
body: &TextBody,
comp: Option<(usize, usize, usize)>,
from: (usize, usize),
limit: usize,
bytes: bool,
) -> ((usize, usize), usize, usize) {
let (mut pi, mut at) = from;
let (mut chars, mut used) = (0usize, 0usize);
// `limit` comes from a (possibly hostile) request: every count is checked, never added
// blindly, so an enormous limit cannot overflow.
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
while let Some(para) = body.paragraphs.get(pi) {
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
if i < at {
continue;
}
let n = ch.len_utf8();
if !fits(used, chars, n) {
return ((pi, i), chars, used);
}
used += n;
chars += 1;
at = i + 1;
}
if pi + 1 >= body.paragraphs.len() {
break;
}
// The paragraph separator between this paragraph and the next.
if !fits(used, chars, 1) {
return ((pi, at), chars, used);
}
used += 1;
chars += 1;
pi += 1;
at = 0;
}
((pi, at), chars, used)
}
/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
/// characters). Returns the position reached and the characters and bytes consumed.
fn walk_backward(
body: &TextBody,
comp: Option<(usize, usize, usize)>,
from: (usize, usize),
limit: usize,
bytes: bool,
) -> ((usize, usize), usize, usize) {
let (mut pi, mut at) = from;
let (mut chars, mut used) = (0usize, 0usize);
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
loop {
let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) };
let mut boundary = at.min(para.char_len());
for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) {
if i >= boundary {
continue;
}
let n = ch.len_utf8();
if !fits(used, chars, n) {
return ((pi, boundary), chars, used);
}
used += n;
chars += 1;
boundary = i;
}
// This paragraph is consumed down to its start.
if pi == 0 {
return ((0, 0), chars, used);
}
if !fits(used, chars, 1) {
return ((pi, 0), chars, used);
}
used += 1;
chars += 1;
pi -= 1;
at = usize::MAX; // all of the previous paragraph is before us
}
}
/// The committed logical text between two model positions (excluding `end`). `None` when it is
/// longer than `cap` bytes: every caller only needs a bounded answer.
fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option<String> {
let (a, b) = if start <= end { (start, end) } else { (end, start) };
let mut out = String::new();
for pi in a.0..=b.0 {
let Some(para) = body.paragraphs.get(pi) else { return Some(out) };
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { usize::MAX };
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
if i < from || i >= to {
continue;
}
out.push(ch);
if out.len() > cap {
return None;
}
}
if pi < b.0 {
out.push('\n');
if out.len() > cap {
return None;
}
}
}
Some(out)
}
/// Committed surrounding text for the text being edited.
///
/// Returns `None` when the body or selection is invalid, or when the selection alone is wider
/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct
/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection.
///
/// Only a bounded window is measured and copied: the query walks at most the budget out from the
/// selection on each side and does not copy the rest of the body. Finding the selection's
/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not
/// bounded independently of the paragraph length.
pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
let body = body_of(st, t)?;
let comp = composition_range(body, st.composition.as_ref());
let caret = model_pos(body, t.caret)?;
let anchor = model_pos(body, t.anchor)?;
let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) };
let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?;
let selection_len = selected.len();
let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len);
let (_, _, before_available) = walk_backward(body, comp, start, budget, true);
let (_, _, after_available) = walk_forward(body, comp, end, budget, true);
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let (window_start, _, _) = walk_backward(body, comp, start, before, true);
let (window_end, _, _) = walk_forward(body, comp, end, after, true);
let before_text = logical_between(body, comp, window_start, start, budget)?;
let after_text = logical_between(body, comp, end, window_end, budget)?;
let before_len = before_text.len();
let mut text = before_text;
text.push_str(&selected);
text.push_str(&after_text);
// The budget arithmetic above already bounds this; refuse rather than publish more.
if text.len() > IME_SURROUNDING_MAX_BYTES {
return None;
}
// The caret and anchor are the selection's own endpoints, so their offsets inside the
// window are the window's start plus either end of the selected text.
let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) };
Some(ImeSurrounding { text, cursor, anchor })
}
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
@@ -932,7 +1195,9 @@ fn compose(s: &mut Session, p: &Value) -> Result<Value> {
// A composition whose text was changed under it is kept as it is; a new one starts.
let current = match current {
Some(c) if !composition_fits(s.doc()?, &c) => {
s.end_composition();
// The text changed under the pre-edit: the app settled it, so the platform's copy
// must be cancelled too.
s.settle_composition();
None
}
c => c,
@@ -1154,6 +1419,93 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
ok()
}
/// A `before`/`after` count from a control or input-method request.
///
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
/// would delete different text than asked.
fn count_param(p: &Value, name: &str) -> usize {
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
}
/// Delete at most the requested character or byte counts around the selection or pre-edit,
/// preserving selection direction and composition boundaries and recording one undo step
/// outside composition.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let before = count_param(p, "before");
let after = count_param(p, "after");
let bytes = bool_or(p, "bytes", false);
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
let comp = composition_range(body, st.composition.as_ref());
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
let ordered = {
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
if a <= b { (a, b) } else { (b, a) }
};
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
// scalars fully inside the requested before/after ranges around it are removed.
let (start, end) = match comp {
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
None => ordered,
};
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
let deleted = before_chars + after_chars;
if deleted == 0 {
return Ok(json!({"deleted": 0}));
}
let before_doc = st.doc.clone();
let before_sel = st.selection.clone();
let composing = st.composition.is_some();
let vertex_kind = s.tool.vertex_kind.clone();
edit_text(s, |body, t| {
// Delete the after-range first, so the positions of the earlier range stay valid; then
// the text before the selection.
if after_chars > 0 {
ed::delete(body, end, to);
}
if before_chars > 0 {
ed::delete(body, from, start);
}
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
t.caret = remap_after_delete(t.caret, start, from, before_chars);
t.line_end = None;
})?;
if composing {
if let Some(c) = s.doc_mut()?.composition.as_mut() {
c.start = if before_chars == 0 {
start
} else if from.0 == start.0 {
(start.0, start.1 - before_chars)
} else {
from
};
}
} else {
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
}
Ok(json!({"deleted": deleted}))
}
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
if before_chars == 0 || pos < start {
return pos;
}
if from.0 == start.0 {
// Only endpoints in the start paragraph move; later paragraphs keep their text.
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
} else {
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
}
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
}
@@ -1583,6 +1935,210 @@ mod rtl_promote_tests {
}
}
#[cfg(test)]
mod ime_surrounding_tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use serde_json::json;
use crate::Session;
/// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id.
fn edit(s: &mut Session) -> u64 {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
id
}
/// Test helper: return the identified shape's text, or an empty string when it is gone.
fn text(s: &Session, id: u64) -> String {
let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap());
s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default()
}
/// Test helper: set an in-paragraph text selection through the engine command path.
fn select(s: &mut Session, anchor: usize, caret: usize) {
s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap();
}
/// DeleteSurrounding removes only the requested text around the selection. The selection
/// itself and its direction stay, so the following commit replaces it.
#[test]
fn selected_text_survives_the_requested_characters_or_bytes_around_it() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀
s.delete_surrounding(1, 1, false).unwrap();
assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
select(&mut s, 1, 3);
s.delete_surrounding(1, 1, true).unwrap();
assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
}
/// A byte range removes only whole scalars fully contained in it. A count that ends inside
/// a character leaves that character whole (and undeleted), never splitting it.
#[test]
fn byte_counts_round_inward_to_whole_characters() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 2, 2); // after é: bytes 0..3
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar");
s.delete_surrounding(2, 0, true).unwrap();
assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
select(&mut s, 2, 2);
s.delete_surrounding(0, 1, true).unwrap();
assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar");
s.delete_surrounding(0, 4, true).unwrap();
assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji");
}
/// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed
/// selection, removes the model-stored pre-edit, and treats an empty body as available.
#[test]
fn surrounding_text_is_committed_logical_and_bounded() {
use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding};
let mut s = Session::with_new();
let id = edit(&mut s);
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8));
s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor");
assert_eq!(q.text, "aé😀b");
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
s.execute("text.move", &json!({"to": "end"})).unwrap();
s.compose_text("日本", None).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text");
s.execute("text.set", &json!({"id": id, "text": ""})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available");
let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500);
s.execute("text.set", &json!({"id": id, "text": long})).unwrap();
s.execute("text.move", &json!({"to": "end"})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len());
assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window");
}
/// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may
/// remove one and merge the paragraphs, exactly as the published offsets say.
#[test]
fn paragraph_marks_are_deletable_logical_text() {
use super::ime_surrounding;
let mut s = Session::with_new();
let id = edit(&mut s);
s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap();
// The caret at the start of the second paragraph: after "ab" and its paragraph mark.
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
let st = s.doc().unwrap();
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3));
// One byte before the caret is that paragraph mark.
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "ab\ncd");
// Characters count the same mark.
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
s.delete_surrounding(1, 0, false).unwrap();
assert_eq!(text(&s, id), "abcd");
s.execute("edit.undo", &json!({})).unwrap();
// And from the other side: one character after the end of the first paragraph.
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
s.delete_surrounding(0, 1, false).unwrap();
assert_eq!(text(&s, id), "abcd");
}
/// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the
/// platform must never see the model's `\u{b}`, which its own text filter would reject.
#[test]
fn soft_line_breaks_read_as_newlines() {
use super::ime_surrounding;
let mut s = Session::with_new();
let id = edit(&mut s);
s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap();
assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph");
let st = s.doc().unwrap();
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2));
// The break is one logical byte, so removing it removes one character.
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "ab");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "a\u{b}b");
}
/// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a
/// different number that would delete other text than the platform asked for.
#[test]
fn out_of_range_counts_clamp_instead_of_truncating() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 1, 1);
s.delete_surrounding(usize::MAX, 0, false).unwrap();
assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic");
let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX);
assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
assert!(expected > 0, "the count is clamped, not wrapped to nothing");
assert_eq!(super::count_param(&json!({}), "before"), 0);
}
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
/// must not be duplicated by the generic command wrapper.
#[test]
fn a_non_composing_ime_deletion_is_one_undo_step() {
let mut s = Session::with_new();
let id = edit(&mut s);
let undo = s.doc().unwrap().history.undo.len();
select(&mut s, 3, 3);
s.delete_surrounding(1, 0, false).unwrap();
assert_eq!(text(&s, id), "aéb");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
}
}
#[cfg(test)]
mod replace_font_tests {
use serde_json::json;
+6 -2
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
use deckcraft_model::anim::TRANSITIONS;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -111,7 +111,11 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
}
match p.get("after") {
Some(Value::Null) => t.advance_after_ms = None,
Some(v) => t.advance_after_ms = v.as_u64().map(|x| x.min(86_400_000) as u32),
Some(v) => {
if let Some(x) = v.as_u64() {
t.advance_after_ms = Some(x.min(86_400_000) as u32);
}
}
None => {}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, TextBody};
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_slide, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+11 -1
View File
@@ -49,7 +49,7 @@ impl Session {
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
/// Returns `{handled: bool, …}`.
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
self.end_composition();
self.settle_composition();
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
let k = key_token(key);
@@ -266,6 +266,16 @@ impl Session {
self.execute("text.commit", &json!({"text": text}))
}
/// Remove text around the composition caret or selection, as an input method asks
/// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8
/// bytes of the logical text the surrounding query publishes, so a range may cross a
/// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range
/// counts are clamped. A deletion while composing stays inside the composition's single undo
/// step.
pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Value> {
self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))
}
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
/// starts with everything selected. False when there is nowhere to type (or nothing to type
/// and nothing being edited).
+42 -2
View File
@@ -155,6 +155,11 @@ pub struct Selection {
pub text: Option<TextSel>,
/// Selected table cells: (table, (r0, c0), (r1, c1)).
pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>,
/// Slide-list range last made with Shift-click: `(anchor, end)`. A further Shift-click extends from
/// the anchor while the current slide is still `end`; moving to another slide drops it. Held as
/// slide ids, so moving, inserting or deleting slides can't make it name other slides.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_anchor: Option<(SlideId, SlideId)>,
pub target: Target,
}
@@ -353,6 +358,9 @@ pub struct Prefs {
pub undo_limit: usize,
/// Minutes between AutoRecover saves of unsaved presentations.
pub recovery_minutes: f64,
/// The unit the rulers, the Format pane and the size dialogs show (Preferences); the deck
/// itself always stores points.
pub units: deckcraft_geom::Unit,
}
impl Default for Prefs {
@@ -376,6 +384,7 @@ impl Default for Prefs {
author: "Presenter".into(),
undo_limit: 500,
recovery_minutes: 1.0,
units: deckcraft_geom::Unit::Inches,
}
}
}
@@ -493,6 +502,8 @@ pub struct Session {
/// Presentations a host read off the UI thread, waiting for `file.adopt` (by token).
loaded: Vec<(u64, cmd::file::LoadedFile)>,
next_loaded: u64,
/// Compositions the application settled itself (see [`Session::composition_settles`]).
composition_settles: u64,
}
impl Default for Session {
@@ -502,6 +513,8 @@ impl Default for Session {
}
impl Session {
/// Install the process panic hook and construct an empty session with default preferences,
/// clipboard, tool state, and no active document.
pub fn new() -> Self {
guard::install_panic_hook();
Session {
@@ -524,6 +537,7 @@ impl Session {
panics_caught: 0,
loaded: vec![],
next_loaded: 0,
composition_settles: 0,
}
}
/// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]);
@@ -610,11 +624,13 @@ impl Session {
self.guarded(id, |s| s.execute_unguarded(id, params))
}
/// Resolve and run a command, settling composition first, checking eligibility, rolling back a
/// failed outer command, and committing the resulting undo entry or journal record.
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// Any other edit (or a click, a key, undo) settles an input method's composition first.
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
self.end_composition();
self.settle_composition();
}
if let Err(e) = (spec.enabled)(self) {
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
@@ -688,6 +704,9 @@ impl Session {
/// Keep an input method's composition as it stands (#101): its text stays and the whole
/// composition becomes one undo step. Nothing happens without one.
///
/// This is also how the input method's own commit ends a composition: the platform has
/// already finished that one, so it must not ask for an interrupt.
pub fn end_composition(&mut self) {
let vertex_kind = self.tool.vertex_kind.clone();
let Some(st) = self.active_mut() else { return };
@@ -697,6 +716,27 @@ impl Session {
}
}
/// Settle a composition because the application did something else (a click, a key, a
/// command, a target change) rather than because the input method committed it.
///
/// The platform still holds its own copy of that composition, so a host must publish one
/// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the
/// backend then cancels it instead of committing its text into whatever is selected next.
pub fn settle_composition(&mut self) {
if self.active().is_some_and(|d| d.composition.is_some()) {
self.composition_settles = self.composition_settles.wrapping_add(1);
}
self.end_composition();
}
/// How many compositions the application settled itself (clicks, keys, other commands).
///
/// A host compares this with the value it last published; every increment is one platform
/// composition that must be interrupted. The input method's own commits do not count.
pub fn composition_settles(&self) -> u64 {
self.composition_settles
}
/// Change only the selection (no undo step).
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
@@ -712,7 +752,7 @@ impl Session {
st.revision += 1;
// A composition belongs to the text body it began in: leaving that body settles it.
if st.composition.is_some() && owner(&st.selection) != before {
self.end_composition();
self.settle_composition();
}
Ok(())
}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
use serde_json::{Value, json};
use crate::*;
use crate::{EngineError, Session, command_specs, sample};
/// Commands that touch the file system, other windows or real time; the soak drives the rest.
fn skipped(id: &str) -> bool {
+10
View File
@@ -357,6 +357,9 @@ impl Dictionary {
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
pub fn check(&self, word: &str) -> bool {
if word.contains('\u{2019}') {
return self.check(&word.replace('\u{2019}', "'"));
}
if word.is_empty() || word.chars().all(|c| !c.is_alphabetic()) {
return true;
}
@@ -800,6 +803,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
}
#[test]
fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
assert!(d.check("don't"));
assert!(d.check("don\u{2019}t"));
}
#[test]
fn roots_affixes_and_cross_products_check() {
let d = d();
+242 -2
View File
@@ -1,6 +1,10 @@
use serde_json::json;
use crate::*;
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::{
DocState, History, Presentation, Session, Target, TextSel, ToolKind, Value, cmd, command_specs, connect, find_command, model, sample, tools,
};
use deckcraft_model::ShapeId;
fn session() -> Session {
Session::with_new()
@@ -210,6 +214,77 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
#[test]
fn pasted_shapes_get_the_next_automatic_name() {
let mut s = session();
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let before = names(&s).len();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
let auto = names(&s)[before].clone();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let all = names(&s);
let base = auto.rsplit_once(' ').unwrap().0;
assert_eq!(all[before + 1], format!("{base} {}", before + 2));
assert_eq!(all[before + 2], format!("{base} {}", before + 3));
// A name the user chose is not an automatic one and stays on the copy.
let id = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.rename", &json!({"id": id, "name": "Hero"})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(names(&s).last().unwrap(), "Hero");
}
#[test]
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
let mut s = session();
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let mut ids = vec![];
for _ in 0..3 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
ids.push(s.doc().unwrap().selection.shapes[0].0);
}
s.execute("edit.delete", &json!({"ids": [ids[1]]})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![deckcraft_model::ShapeId(ids[2])];
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let all = names(&s);
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn pasting_on_another_slide_keeps_names_so_morph_still_pairs_them() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 20, 20]})).unwrap();
let first = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 200, 80, 80]})).unwrap();
s.execute("edit.delete", &json!({"ids": [first]})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
let (a, b) = (&st.doc.slides[1], &st.doc.slides[2]);
let an: Vec<_> = a.shapes.iter().map(|x| x.name.clone()).collect();
let bn: Vec<_> = b.shapes.iter().map(|x| x.name.clone()).collect();
assert_eq!(an, bn);
let pairs = deckcraft_anim::morph_pairs(a, b);
assert_eq!(pairs.len(), 2);
for (ia, ib) in pairs {
let na = &a.shapes.iter().find(|x| x.id == ia).unwrap().name;
let nb = &b.shapes.iter().find(|x| x.id == ib).unwrap().name;
assert_eq!(na, nb);
}
}
#[test]
fn bold_copy_is_html_and_pages_html_pastes_bold() {
let mut s = session();
@@ -4088,6 +4163,37 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
// A file-defined weight needs a real regular/bold pair, independent of missing Inter
// or whichever system face happened to load first. Leave bold unset for the table style.
let fonts = deckcraft_fonts::FontDb::global();
let regular = fonts.face("BIZ UDPGothic", "Regular");
let bold = fonts.face("BIZ UDPGothic", "Bold");
let metric_pair = regular.family == "BIZ UDPGothic"
&& bold.family == "BIZ UDPGothic"
&& regular.style == "Regular"
&& bold.style == "Bold"
&& regular.weight < bold.weight;
// Required native builds validate and embed both BIZ faces (crates/fonts/build.rs);
// optional local builds may only have Regular, so the weight assertion is skipped there.
assert!(
metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(),
"required native build is missing the BIZ UDPGothic Regular/Bold metric pair"
);
if metric_pair {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
let deckcraft_model::ShapeKind::Table(table) = &mut slide.shape_mut(id).unwrap().kind else { panic!("not a table") };
for cell in &mut table.rows[0].cells {
for paragraph in &mut cell.text.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("BIZ UDPGothic".into());
run.props.bold = None;
}
}
}
} else {
eprintln!("optional BIZ pair absent: skipping weight-metric assertion; edit/resize checks still run");
}
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let built_in = caret_point(&s, 0, 8);
s.execute("text.exit", &json!({})).unwrap();
@@ -4112,7 +4218,9 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
s.execute("edit.deselect", &json!({})).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let p = caret_point(&s, 0, 8);
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
if metric_pair {
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
}
s.execute("text.exit", &json!({})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
// A double-click selects the word drawn there.
@@ -4438,6 +4546,64 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
}
/// Review › Language › Whole presentation: every run, placeholder list style and default text style
/// takes the language, with nothing selected, in one undo step.
#[test]
fn language_all_marks_every_text_in_the_deck_with_nothing_selected() {
crate::spell::install("xx-AL", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n"));
let mut s = session();
let a =
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello wurld"})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
s.execute("format.language", &json!({"lang": "de-DE"})).unwrap();
s.execute("slide.new", &json!({"title": "world hellow"})).unwrap();
s.execute("insert.table", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
// A layout placeholder with its own list style (a:lstStyle) in another language.
let placeholder = s
.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.first_mut().unwrap());
let sh = m.layouts[0].shapes.iter_mut().find(|sh| sh.text.is_some()).unwrap();
let lv = deckcraft_model::text::LevelStyle {
run: deckcraft_model::RunProps { lang: Some("de-DE".into()), ..Default::default() },
..Default::default()
};
sh.text.as_mut().unwrap().list_style.set(0, lv);
Ok(sh.id)
})
.unwrap();
s.execute("format.languageAll", &json!({"lang": "xx-AL"})).unwrap();
let st = s.doc().unwrap();
let ph = deckcraft_model::find_shape(&st.doc.masters[0].layouts[0].shapes, placeholder).unwrap();
assert_eq!(ph.text.as_ref().unwrap().list_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
// A body without a list style doesn't get one.
let boxed = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert!(boxed.text.as_ref().unwrap().list_style.is_empty());
let mut langs = vec![];
for sl in &st.doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
langs.extend(sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.lang.clone()));
if let deckcraft_model::ShapeKind::Table(t) = &sh.kind {
langs.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.text.paragraphs).map(|p| p.end_props.lang.clone()));
}
});
}
assert!(!langs.is_empty() && langs.iter().all(|l| l.as_deref() == Some("xx-AL")), "{langs:?}");
// The defaults new text inherits.
assert_eq!(st.doc.default_text_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
assert!(st.doc.masters.iter().all(|m| m.body_style.level(0).is_some_and(|l| l.run.lang.as_deref() == Some("xx-AL"))));
let words: Vec<String> =
s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().iter().map(|w| w["word"].as_str().unwrap().to_string()).collect();
assert_eq!(words, vec!["wurld", "hellow"]);
// One undo step puts the per-box language back.
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let sh = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert_eq!(sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.lang.as_deref(), Some("de-DE"));
assert!(s.execute("format.languageAll", &json!({})).is_err());
assert!(s.execute("format.languageAll", &json!({"lang": "en US!"})).is_err());
}
#[test]
fn the_caret_takes_the_colour_of_the_text() {
let caret = |theme: &str| {
@@ -4846,6 +5012,55 @@ fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
}
#[test]
fn table_row_col_commands_reject_an_invalid_index() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
for (cmd, key) in [
("table.deleteRow", "row"),
("table.deleteColumn", "col"),
("table.insertRowAbove", "row"),
("table.insertColumnLeft", "col"),
("table.split", "row"),
] {
for bad in [json!(-1), json!(1.5), json!("1")] {
let r = s.execute(cmd, &json!({"id": id.0, key: bad}));
assert!(r.is_err(), "{cmd} {key}={bad} should be rejected, got {r:?}");
let t = table_of(&s, id);
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {key}={bad} must leave the table unchanged");
}
}
}
#[test]
fn table_cell_commands_reject_an_invalid_cell_and_name_the_registered_command() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
s.execute("table.selectCells", &json!({"id": id.0, "from": [0, 0], "to": [1, 1]})).unwrap();
for (cmd, params) in [
("table.split", json!({"id": id.0, "cell": [-1, 0]})),
("table.merge", json!({"id": id.0, "from": [-1, 0], "to": [1, 1]})),
("table.merge", json!({"id": id.0, "from": [0, 0], "to": "x"})),
("table.rowHeight", json!({"id": id.0, "row": -1, "height": 50.0})),
("table.rowHeight", json!({"id": id.0, "row": 1.5, "height": 50.0})),
("table.columnWidth", json!({"id": id.0, "col": -1, "width": 50.0})),
("table.columnWidth", json!({"id": id.0, "col": "1", "width": 50.0, "keepTotal": true})),
] {
let before = table_of(&s, id);
let (rows, cols) = (before.rows.iter().map(|r| r.height).collect::<Vec<_>>(), before.cols.clone());
let r = s.execute(cmd, &params);
assert!(r.is_err(), "{cmd} {params} should be rejected, got {r:?}");
let t = table_of(&s, id);
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {params} must leave the table unchanged");
assert_eq!(t.rows.iter().map(|r| r.height).collect::<Vec<_>>(), rows, "{cmd} {params} must not change row heights");
assert_eq!(t.cols, cols, "{cmd} {params} must not change column widths");
}
let e = s.execute("table.deleteRow", &json!({"id": id.0, "row": -1})).unwrap_err().to_string();
assert!(e.contains("table.deleteRow"), "error should name the registered command: {e}");
let e = s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": -1})).unwrap_err().to_string();
assert!(e.contains("table.insertColumnLeft"), "error should name the registered command: {e}");
}
#[test]
fn table_column_width_of_a_huge_column_is_ignored() {
let mut s = session();
@@ -5406,3 +5621,28 @@ fn slide_hide_and_layout_reject_invalid_index() {
}
assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden));
}
#[test]
fn transition_timing_invalid_after_keeps_value() {
let mut s = session();
let after = |s: &Session| s.doc().unwrap().doc.slides[0].transition.as_ref().and_then(|t| t.advance_after_ms);
s.execute("transition.timing", &json!({"after": 5000})).unwrap();
assert_eq!(after(&s), Some(5000));
for bad in [json!(-5), json!(1.5), json!("abc")] {
let _ = s.execute("transition.timing", &json!({"after": bad}));
assert_eq!(after(&s), Some(5000), "after:{bad} must not clear the auto-advance");
}
s.execute("transition.timing", &json!({"after": null})).unwrap();
assert_eq!(after(&s), None);
}
#[test]
fn invalid_ids_array_never_falls_back_to_the_selection() {
let mut s = session();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
for ids in [json!([-1]), json!([1.5]), json!([4294967296u64]), json!([-1, a])] {
let r = s.execute("edit.delete", &json!({ "ids": ids }));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "invalid ids {ids} gave {r:?}");
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
}
}
+1 -1
View File
@@ -459,7 +459,7 @@ impl Session {
}
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
if !matches!(ev.kind, PointerKind::Move) {
self.end_composition();
self.settle_composition();
}
match ev.kind {
PointerKind::Move => {
+1 -1
View File
@@ -15,4 +15,4 @@ harfrust = { workspace = true }
unicode-bidi = { workspace = true }
log = { workspace = true }
# Last-resort face only (Ubuntu Light); no UI code.
epaint_default_fonts = "0.36"
epaint_default_fonts = "0.37"
+466 -29
View File
@@ -1,27 +1,61 @@
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
//! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as
//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter
//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the
//! local checkout: no network.
//! `CRAFT_FONTS`, Latin first. Native builds additionally read
//! `fonts/simplified-chinese-manifest.txt`; it is optional for ordinary local builds but a required
//! native input (`CRAFT_FONTS_REQUIRED`) fails without it. Unset, `CRAFT_FONTS` is empty. Web
//! (wasm32) builds embed only Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic
//! Regular, to stay within the web size budget; native builds also carry BIZ UDPGothic Bold.
//! Required builds verify the faces they promise survive exclusion (and, on wasm, the web filter),
//! so a pin without the Latin manifest or the native bold face fails instead of silently dropping
//! them. It only reads the local checkout: no network.
use std::fmt::Write as _;
use std::path::PathBuf;
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design).
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"];
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design, and no Noto
/// CJK rebrand of Source Han on any platform).
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"];
/// Reject source families prohibited by the asset policy, including Source Han and every
/// Noto CJK/Serif/Mono CJK variant.
fn is_excluded_family(family: &str) -> bool {
EXCLUDED_FAMILIES.contains(&family)
|| family.starts_with("Source Han Sans")
|| family.starts_with("Source Han Serif")
|| family.starts_with("Noto Sans CJK")
|| family.starts_with("Noto Serif CJK")
|| family.starts_with("Noto Sans Mono CJK")
}
/// The generated initialisers plus the faces that were actually embedded after exclusion and web
/// filtering. Validation must use the latter, not the raw manifest text.
#[derive(Default)]
struct FontInput {
source: String,
faces: Vec<(String, String)>,
}
impl FontInput {
/// Concatenate another input's generated source and embedded-face list into this one.
fn append(&mut self, other: Self) {
self.source.push_str(&other.source);
self.faces.extend(other.faces);
}
}
/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input
/// failures as warnings unless required, and always fail when the generated file cannot be written.
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
match input_fonts(dir.as_deref(), wasm, required) {
Ok(entries) => src.push_str(&entries),
Err(e) if required => println!("cargo::error={e}"),
Err(e) => println!("cargo::warning=building without craft-fonts: {e}"),
}
src.push_str("];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
@@ -31,30 +65,88 @@ fn main() {
}
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let mut out = String::new();
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
let latin = dir.join("fonts/latin-manifest.txt");
println!("cargo::rerun-if-changed={}", latin.display());
if latin.exists() {
out.push_str(&manifest(dir, &latin)?);
}
out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?);
Ok(out)
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<String, String> {
load_fonts(dir, wasm, required).map(|input| input.source)
}
fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String, String> {
/// Use no fonts when the directory is absent unless required; otherwise load the manifests.
fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<FontInput, String> {
match dir {
Some(dir) => craft_fonts(dir, wasm, required),
None if required => Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()),
None => Ok(FontInput::default()),
}
}
/// Load optional Latin input, required main input, and native-only Chinese input, then validate
/// the promised required faces after filtering.
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<FontInput, String> {
let mut input = FontInput::default();
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
// A malformed or incomplete optional manifest is skipped as a unit: the main Japanese/symbol/RTL
// faces and the native Chinese addition stay usable, and required builds still fail validation.
let latin = dir.join("fonts/latin-manifest.txt");
println!("cargo::rerun-if-changed={}", latin.display());
let latin_exists = match latin.try_exists() {
Ok(exists) => exists,
Err(e) if required => return Err(format!("{}: {e}", latin.display())),
Err(e) => {
println!("cargo::warning=skipping optional Latin fonts: {}: {e}", latin.display());
false
}
};
if latin_exists {
match manifest(dir, &latin, wasm) {
Ok(entries) => input.append(entries),
Err(e) if required => return Err(e),
Err(e) => println!("cargo::warning=skipping optional Latin fonts: {e}"),
}
}
input.append(manifest(dir, &dir.join("fonts/manifest.txt"), wasm)?);
// Native consumers opt in; older craft-fonts checkouts need not carry this manifest.
// An invalid optional addition must never discard the main Japanese/symbol/RTL faces.
if !wasm {
let chinese = dir.join("fonts/simplified-chinese-manifest.txt");
println!("cargo::rerun-if-changed={}", chinese.display());
let exists = match chinese.try_exists() {
Ok(exists) => exists,
Err(e) if required => return Err(format!("{}: {e}", chinese.display())),
Err(e) => {
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display());
false
}
};
if exists {
match manifest(dir, &chinese, false) {
Ok(entries) => input.append(entries),
Err(e) if required => return Err(e),
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"),
}
} else if required {
// A required native font input that silently lacks the reviewed Chinese manifest
// would make the Chinese UI/canvas test pass only by skipping.
return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display()));
}
}
if required {
validate_required_faces(&input.faces, wasm)?;
}
Ok(input)
}
/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter,
/// canonicalise assets, and emit initialisers.
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<FontInput, String> {
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
let mut input = FontInput::default();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
// DeckCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones.
if EXCLUDED_FAMILIES.contains(family) {
if is_excluded_family(family) {
continue;
}
if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) {
@@ -63,12 +155,357 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String,
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
input.faces.push(((*family).to_owned(), (*style).to_owned()));
let _ = writeln!(
out,
input.source,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
scripts.join(", "),
path.display().to_string(),
);
}
Ok(out)
Ok(input)
}
/// Required builds must actually carry the faces the platform promises: Inter Regular and Bold
/// (or one Variable face) and BIZ UDPGothic Regular everywhere, BIZ UDPGothic Bold natively (the
/// weight-pair metric test relies on it), plus LXGW WenKai Lite Regular for native builds. This
/// runs after exclusion and, on wasm, the web filter, so an absent manifest, empty/all-excluded
/// rows, a wrong-family row or a partial catalog cannot satisfy the gate. Wasm deliberately
/// embeds only BIZ Regular to stay within the web size budget.
fn validate_required_faces(faces: &[(String, String)], wasm: bool) -> Result<(), String> {
let has = |family: &str, style: &str| faces.iter().any(|(f, s)| f == family && s == style);
let inter = has("Inter", "Variable") || (has("Inter", "Regular") && has("Inter", "Bold"));
let biz_regular = has("BIZ UDPGothic", "Regular");
let biz_bold = wasm || has("BIZ UDPGothic", "Bold");
let chinese = wasm || has("LXGW WenKai Lite", "Regular");
let mut missing = Vec::new();
if !inter {
missing.push("Inter Regular/Bold (or Inter Variable)");
}
if !biz_regular {
missing.push("BIZ UDPGothic Regular");
}
if !biz_bold {
missing.push("BIZ UDPGothic Bold");
}
if !chinese {
missing.push("LXGW WenKai Lite Regular");
}
if missing.is_empty() {
Ok(())
} else {
Err(format!("CRAFT_FONTS_REQUIRED is set but the embedded font set after filtering lacks {}", missing.join(" and ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
const CHINESE_ROW: &str = "LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
struct InputFixture(PathBuf);
impl InputFixture {
/// Test helper: create a unique temporary fixture with a main manifest and asset file.
fn new() -> Self {
let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&path).unwrap();
std::fs::create_dir(path.join("fonts")).unwrap();
std::fs::write(path.join("fonts/fixture.ttf"), b"build-input path fixture").unwrap();
std::fs::write(
path.join("fonts/manifest.txt"),
"BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
BIZ UDPGothic | Bold | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
)
.unwrap();
Self(path)
}
/// Test helper: replace the fixture's main manifest contents.
fn main(&self, text: &str) {
std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap();
}
/// Test helper: write the fixture's optional Latin manifest.
fn latin(&self, text: &str) {
std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap();
}
/// Test helper: remove the optional Latin manifest.
fn no_latin(&self) {
let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt"));
}
/// Test helper: install a valid Inter Regular/Bold Latin manifest.
fn valid_latin(&self) {
self.latin(
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
);
}
/// Test helper: write the optional native Chinese manifest with the supplied rows.
fn optional(&self, text: &str) {
std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap();
}
}
impl Drop for InputFixture {
/// Test helper: best-effort removal of the temporary fixture directory.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A required build without a font directory fails; an optional build embeds no fonts.
#[test]
fn required_input_cannot_silently_disappear() {
assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR"));
assert_eq!(input_fonts(None, false, false).unwrap(), "");
}
/// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog
/// unchanged in optional builds, while a required build fails.
#[test]
fn broken_optional_manifest_preserves_the_main_fonts() {
let fixture = InputFixture::new();
let main = input_fonts(Some(&fixture.0), false, false).unwrap();
fixture.optional("malformed row");
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
fixture.optional("LXGW WenKai Lite | Regular | missing.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid");
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
}
/// Adding or breaking native Chinese input must not change the wasm font initialiser bytes.
#[test]
fn native_opt_in_does_not_change_wasm_embedded_bytes() {
let fixture = InputFixture::new();
fixture.valid_latin();
let before = input_fonts(Some(&fixture.0), true, true).unwrap();
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(native.contains("LXGW WenKai Lite"));
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
fixture.optional("malformed row");
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
}
/// Required native builds fail without the Chinese manifest; optional native and wasm builds
/// stay valid.
#[test]
fn required_native_input_requires_the_chinese_manifest() {
let fixture = InputFixture::new();
fixture.valid_latin();
assert!(input_fonts(Some(&fixture.0), false, true).unwrap_err().contains("simplified-Chinese"));
// Optional native builds and wasm keep working without it.
assert!(input_fonts(Some(&fixture.0), false, false).is_ok());
assert!(input_fonts(Some(&fixture.0), true, true).is_ok());
}
/// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and
/// Hebrew faces survive.
#[test]
fn all_noto_cjk_families_are_excluded_from_every_platform() {
for family in [
"Noto Sans CJK SC",
"Noto Sans CJK TC",
"Noto Sans CJK JP",
"Noto Sans CJK KR",
"Noto Sans CJK HK",
"Noto Serif CJK SC",
"Noto Serif CJK JP",
"Noto Sans Mono CJK SC",
"Source Han Sans SC",
"Source Han Serif JP",
] {
assert!(is_excluded_family(family), "{family} would be embedded");
}
for family in ["LXGW WenKai Lite", "Noto Sans Hebrew", "Inter", "Noto Sans Symbols 2"] {
assert!(!is_excluded_family(family), "{family} is an allowed input");
}
let fixture = InputFixture::new();
fixture.valid_latin();
let mut rows = String::from(CHINESE_ROW);
for family in ["Noto Sans CJK SC", "Noto Serif CJK JP", "Noto Sans Mono CJK KR"] {
rows.push_str(&format!("{family} | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"));
}
fixture.optional(&rows);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(!native.contains("Noto Sans CJK") && !native.contains("Noto Serif CJK") && !native.contains("Noto Sans Mono CJK"));
assert!(native.contains("BIZ UDPGothic"), "the allowed main manifest is retained");
assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained");
}
/// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a
/// nonempty manifest.
#[test]
fn required_native_chinese_manifest_must_emit_the_reviewed_face() {
let fixture = InputFixture::new();
fixture.valid_latin();
let excluded = "Noto Sans CJK SC | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Noto Serif CJK JP | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
let wrong_family = "LXGW WenKai | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
for rows in ["", excluded, wrong_family] {
fixture.optional(rows);
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
assert!(e.contains("LXGW WenKai Lite Regular"), "rows={rows:?}: {e}");
assert!(input_fonts(Some(&fixture.0), false, false).is_ok(), "optional builds still preserve the valid core");
}
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(native.contains("CraftFont { family: \"LXGW WenKai Lite\", style: \"Regular\""), "{native}");
assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained");
}
/// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese
/// faces; required builds still fail.
#[test]
fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
let missing_asset = "Inter | Regular | fonts/missing.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
for latin in [None, Some("malformed row"), Some(missing_asset)] {
match latin {
Some(text) => fixture.latin(text),
None => fixture.no_latin(),
}
for wasm in [false, true] {
let catalog = input_fonts(Some(&fixture.0), wasm, false).unwrap();
assert!(catalog.contains("BIZ UDPGothic"), "wasm={wasm}: the main manifest is retained");
assert!(!catalog.contains("Inter"), "wasm={wasm}: the broken Latin manifest is skipped as a unit");
if !wasm {
assert!(catalog.contains("LXGW WenKai Lite"), "the valid Chinese addition is retained");
}
}
assert!(input_fonts(Some(&fixture.0), false, true).is_err(), "required builds must not skip the missing required faces");
}
}
/// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm
/// remains a three-face subset.
#[test]
fn full_prior_latin_catalog_keeps_permitted_native_families() {
let fixture = InputFixture::new();
fixture.latin(include_str!("tests/fixtures/latin-families.txt"));
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert_eq!(native.matches("CraftFont {").count(), 46, "43 permitted Latin faces plus BIZ Regular/Bold and LXGW");
for family in [
"Carlito",
"Caladea",
"Liberation Serif",
"Montserrat",
"Lato",
"Open Sans",
"Roboto",
"Merriweather",
"Playfair Display",
"Poppins",
"Nunito Sans",
] {
assert!(native.contains(family), "missing prior presentation family {family}");
}
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
assert_eq!(web.matches("CraftFont {").count(), 3, "Inter Regular/Bold and BIZ only");
for catalog in [&native, &web] {
assert!(!catalog.contains("Source Sans 3"));
assert!(!catalog.contains("Source Serif 4"));
}
}
/// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is
/// mandatory.
#[test]
fn required_builds_need_the_latin_catalog_for_inter() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
/// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required
/// Inter contract.
#[test]
fn required_builds_reject_partial_or_unrelated_faces() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
for latin in [
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Inter | Italic | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | SemiBold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Carlito | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Source Sans 3 | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Source Sans 3 | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
] {
fixture.latin(latin);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
}
/// BIZ UDPGothic Regular is required on every platform; required native builds additionally
/// need Bold.
#[test]
fn required_builds_need_biz_udpgothic_regular() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.main(
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
);
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("BIZ UDPGothic Regular"), "wasm={wasm}: {e}");
if !wasm {
assert!(e.contains("BIZ UDPGothic Bold"), "native builds need the weight pair: {e}");
}
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
/// Native required builds need the real BIZ Bold face for weight metrics; wasm deliberately
/// embeds only Regular to stay within its size budget. This pins the contract the engine's
/// required-build metric pair assertion relies on.
#[test]
fn required_native_builds_need_biz_bold_but_wasm_does_not() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.optional(CHINESE_ROW);
fixture.main("BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
assert!(e.contains("BIZ UDPGothic Bold"), "required native build accepted a Regular-only catalog: {e}");
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only");
}
/// Required checks accept either static Inter Regular+Bold or a single Variable face, together
/// with BIZ.
#[test]
fn required_builds_accept_static_or_variable_inter_with_biz() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let static_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
assert!(static_catalog.contains("Inter") && static_catalog.contains("BIZ UDPGothic"));
}
fixture.latin("Inter | Variable | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
for wasm in [true, false] {
let variable_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
assert!(variable_catalog.contains("Inter") && variable_catalog.contains("BIZ UDPGothic"));
}
}
}
+22
View File
@@ -409,16 +409,38 @@ mod tests {
assert!(feature("liga").is_some());
}
/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces;
/// required builds also assert their presence.
#[test]
fn craft_fonts_latin_when_present() {
let required = option_env!("CRAFT_FONTS_REQUIRED").is_some();
if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") {
assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest");
eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)");
return;
}
let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style);
let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable"));
if required {
assert!(
variable.is_some() || (regular.is_some() && bold.is_some()),
"CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)"
);
}
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.face("Inter", "Regular").family, "Inter");
assert_eq!(db.face("Aptos", "Regular").family, "Inter");
if let (Some(regular), Some(bold)) = (regular, bold) {
// The build input carries one entry per face; real static Bold must not be the Regular
// file relabelled in the manifest.
assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular");
let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold"));
assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular"));
assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold"));
assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face");
assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight);
}
if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") {
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
}
+4
View File
@@ -0,0 +1,4 @@
//! Exercise the build-input parser without changing process environment variables.
#[allow(dead_code)]
#[path = "../build.rs"]
mod build_script;
+52
View File
@@ -0,0 +1,52 @@
# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde.
Inter | Regular | fonts/fixture.ttf | Latn
Inter | Italic | fonts/fixture.ttf | Latn
Inter | SemiBold | fonts/fixture.ttf | Latn
Inter | Bold | fonts/fixture.ttf | Latn
Carlito | Regular | fonts/fixture.ttf | Latn
Carlito | Bold | fonts/fixture.ttf | Latn
Carlito | Italic | fonts/fixture.ttf | Latn
Carlito | Bold Italic | fonts/fixture.ttf | Latn
Caladea | Regular | fonts/fixture.ttf | Latn
Caladea | Bold | fonts/fixture.ttf | Latn
Caladea | Italic | fonts/fixture.ttf | Latn
Caladea | Bold Italic | fonts/fixture.ttf | Latn
Liberation Sans | Regular | fonts/fixture.ttf | Latn
Liberation Sans | Bold | fonts/fixture.ttf | Latn
Liberation Sans | Italic | fonts/fixture.ttf | Latn
Liberation Sans | Bold Italic | fonts/fixture.ttf | Latn
Liberation Serif | Regular | fonts/fixture.ttf | Latn
Liberation Serif | Bold | fonts/fixture.ttf | Latn
Liberation Serif | Italic | fonts/fixture.ttf | Latn
Liberation Serif | Bold Italic | fonts/fixture.ttf | Latn
Liberation Mono | Regular | fonts/fixture.ttf | Latn
Liberation Mono | Bold | fonts/fixture.ttf | Latn
Liberation Mono | Italic | fonts/fixture.ttf | Latn
Liberation Mono | Bold Italic | fonts/fixture.ttf | Latn
Source Sans 3 | Regular | fonts/fixture.ttf | Latn
Source Sans 3 | Italic | fonts/fixture.ttf | Latn
Source Sans 3 | Semibold | fonts/fixture.ttf | Latn
Source Sans 3 | Bold | fonts/fixture.ttf | Latn
Source Serif 4 | Regular | fonts/fixture.ttf | Latn
Source Serif 4 | Italic | fonts/fixture.ttf | Latn
Source Serif 4 | Semibold | fonts/fixture.ttf | Latn
Source Serif 4 | Bold | fonts/fixture.ttf | Latn
Montserrat | Variable | fonts/fixture.ttf | Latn
Montserrat | Italic Variable | fonts/fixture.ttf | Latn
Lato | Regular | fonts/fixture.ttf | Latn
Lato | Bold | fonts/fixture.ttf | Latn
Lato | Italic | fonts/fixture.ttf | Latn
Open Sans | Variable | fonts/fixture.ttf | Latn
Open Sans | Italic Variable | fonts/fixture.ttf | Latn
Roboto | Variable | fonts/fixture.ttf | Latn
Roboto | Italic Variable | fonts/fixture.ttf | Latn
Merriweather | Regular | fonts/fixture.ttf | Latn
Merriweather | Bold | fonts/fixture.ttf | Latn
Merriweather | Italic | fonts/fixture.ttf | Latn
Playfair Display | Variable | fonts/fixture.ttf | Latn
Playfair Display | Italic Variable | fonts/fixture.ttf | Latn
Poppins | Regular | fonts/fixture.ttf | Latn
Poppins | Bold | fonts/fixture.ttf | Latn
Poppins | Italic | fonts/fixture.ttf | Latn
Nunito Sans | Variable | fonts/fixture.ttf | Latn
Nunito Sans | Italic Variable | fonts/fixture.ttf | Latn
+5 -2
View File
@@ -1,6 +1,9 @@
use super::*;
use std::io::{Cursor, Write};
use std::sync::Arc;
use super::{Budget, Presentation, load, load_checked, load_within, read_entry, read_entry_max, save};
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
use deckcraft_model::{Master, Shape, ShapeId};
use deckcraft_model::{Master, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
const PART: &str = "ppt/embeddings/owned.xlsx";
+52 -4
View File
@@ -57,9 +57,17 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
for s in &p.slides {
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
Some(tp) => runs(&mut out, tp),
None => escape(&mut out, &s.title()),
// The title stays one outline paragraph (a `\par` would start another slide on re-import),
// so its paragraphs are joined with line breaks.
let mut tps = title.into_iter().flat_map(|t| &t.paragraphs).filter(|p| !p.is_empty()).peekable();
if tps.peek().is_none() {
escape(&mut out, &s.title());
}
for (k, tp) in tps.enumerate() {
if k > 0 {
out.push_str("\\line ");
}
runs(&mut out, tp);
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
@@ -104,6 +112,12 @@ const SKIP: &[&str] = &[
"rsidtbl",
"object",
"fldinst",
// A list item's rendered bullet or number, written before its text (Word: listtext; RTF 1.5 and
// older writers: pntext, pntxta, pntxtb). Its `\pard` must not reset the paragraph's indent.
"listtext",
"pntext",
"pntxta",
"pntxtb",
];
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
@@ -223,7 +237,7 @@ pub fn rtf_to_outline(rtf: &str) -> String {
_ => {}
}
} else if n == b'\'' {
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
let hex = rtf.get(i + 1..i + 3).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u8::from_str_radix(h, 16).ok());
i = advance(rtf, i, 3);
if !st.skip
&& let Some(h) = hex
@@ -239,6 +253,7 @@ pub fn rtf_to_outline(rtf: &str) -> String {
match n {
b'\\' | b'{' | b'}' => line.push(n as char),
b'~' => line.push('\u{a0}'),
b'_' => line.push('\u{2011}'),
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
_ => {}
}
@@ -318,6 +333,12 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// A sign in a `\'hh` escape is not a hex digit and must be ignored.
#[test]
fn hex_escape_rejects_sign() {
assert_eq!(rtf_to_outline(r"{\rtf1 a\'+5b\par}"), "ab\n");
}
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
#[test]
fn non_ascii_text_after_escapes_does_not_panic() {
@@ -337,6 +358,12 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
assert_eq!(rtf_to_outline(r"{\rtf1\pard Year\_end review\par}"), "Year\u{2011}end review\n");
}
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
#[test]
fn huge_left_indent_gives_the_deepest_level() {
@@ -355,9 +382,30 @@ mod tests {
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
/// A title with two paragraphs: the plain outline keeps both, the RTF export kept only the first.
#[test]
fn every_title_paragraph_is_exported() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
crate::outline_to_slides(&mut p, "First line\n\tBody\n");
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
let title = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_mut()).unwrap();
title.paragraphs.push(Paragraph::new("Second line"));
let rtf = slides_to_rtf(&p);
assert!(rtf.contains("First line\\line Second line\\b0\\par"), "{rtf}");
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
#[test]
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
}
/// Word writes a list item's bullet as `{\\listtext\\pard\\plain …\\'b7\\tab}` (older writers:
/// `{\\pntext …}`) after the paragraph's own `\\li`. That group is the rendered bullet, not text.
#[test]
fn word_list_bullets_are_not_text_and_keep_the_indent() {
let rtf = r"{\rtf1\ansi\pard Title\par\pard\fi-360\li720{\listtext\pard\plain\f1\'b7\tab}Point\par\pard\li720{\pntext\f1\'b7\tab}Old\par}";
assert_eq!(rtf_to_outline(rtf), "Title\n\t\tPoint\n\t\tOld\n");
}
}
+63 -5
View File
@@ -1334,7 +1334,9 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let (y1, y2) = (h / 2.0 - h * k / 2.0, h / 2.0 + h * k / 2.0);
let dx = (ss * a(1).max(0.0) / 1e5).min(w);
if name == "notchedRightArrow" {
let n = dx * (k / 2.0) * 0.9;
// x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's
// edges run parallel to the head's
let n = dx * k;
let pts = [(0.0, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (0.0, y2), (n, h / 2.0)];
g.paths = vec![fill(Pb::new().poly(&pts).take())];
} else {
@@ -1860,10 +1862,10 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
let r = r.min(w / 2.0).min(h / 2.0);
// Walk clockwise from the top-left; insert the wedge on the edge facing the tip.
let horiz = dx.abs() > dy.abs();
let (bx0, bx1) = (w * 5.0 / 12.0, w * 7.0 / 12.0);
let (by0, by1) = (h * 5.0 / 12.0, h * 7.0 / 12.0);
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (bx0 + w / 12.0 * 2.0 - w / 12.0 * 2.0, bx1 + w * 2.0 / 12.0) };
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (by0, by1 + h * 2.0 / 12.0) };
// The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is
// before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2).
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) };
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.0 / 12.0) };
b.m(r, 0.0);
if !inside && !horiz && dy < 0.0 {
b.l(bx0, 0.0).l(tip.x, tip.y).l(bx1, 0.0);
@@ -2454,6 +2456,20 @@ mod tests {
assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6));
}
#[test]
fn notched_arrow_notch_parallels_the_head() {
// presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run
// parallel to the head's (25 on a 200 × 100 arrow at the default adjusts).
for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] {
let g = build("notchedRightArrow", w, h, &adj).unwrap();
let notch = g.paths[0].path.elements().iter().find_map(|e| match e {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p),
_ => None,
});
assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}");
}
}
#[test]
fn equation_shapes_match_powerpoints_outlines() {
// Corners read off PowerPoint's own outlines of the presets at their default adjusts.
@@ -2590,4 +2606,46 @@ mod tests {
assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined");
}
}
/// The end points of a path's move/line elements, in order.
fn vertices(p: &BezPath) -> Vec<Point> {
p.elements()
.iter()
.filter_map(|e| match e {
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q),
_ => None,
})
.collect()
}
#[test]
fn wedge_callout_mirrors_with_its_tip() {
// A tip below and right of the centre draws the mirror image of the same tip on the left;
// the same for a tip right of the centre, above or below it. presetShapeDefinitions puts
// the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at
// 7..10 twelfths on the far side.
let (w, h) = (240.0, 120.0);
// Sorted and deduplicated: the outline closes on the corner it starts from, which the
// mirror moves to the opposite corner.
let sorted = |mut v: Vec<Point>| {
v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9);
v
};
for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] {
let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path);
let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path);
let b: Vec<Point> = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect();
let (a, b) = (sorted(a), sorted(b));
assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}");
for (p, q) in a.iter().zip(&b) {
assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}");
}
}
// The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width.
let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path);
for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] {
assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}");
}
}
}
+4 -1
View File
@@ -2,7 +2,10 @@
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
//! construction, fitted to how the shapes look and to what their adjust values mean.
use super::*;
use super::{
Corner, Dir, FillMode, Geometry, Handle, Pb, Point, Rect, Vec2, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple,
stroke_only, vis,
};
/// Builds the presets this module knows; `None` for any other name.
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Band, BezPath, CATALOG, Guide, Point, Rect, WarpLine, adjust_count, adjust_defaults, band_of_line, handles, is_follow, is_supported, warp,
};
const W: f64 = 420.0;
const H: f64 = 200.0;
+15 -4
View File
@@ -327,12 +327,18 @@ impl Dpb {
had5 = true;
}
6 => {
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
let allowed = match self.max_long_term_frame_idx {
Some(max) => m.long_term_frame_idx <= max,
None => false,
};
if allowed {
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
}
}
*cur_long = Some(m.long_term_frame_idx);
}
*cur_long = Some(m.long_term_frame_idx);
}
_ => {}
}
@@ -410,6 +416,11 @@ impl Dpb {
} else {
while self.entries.len() >= self.capacity {
if !self.bump(out) {
if let Some(pos) = self.entries.iter().position(|e| !e.needed_for_output) {
self.entries.remove(pos);
} else {
self.entries.remove(0);
}
break;
}
}
+39
View File
@@ -91,11 +91,21 @@ fn clip_u8(v: i32) -> u8 {
v.clamp(0, 255) as u8
}
/// A dequantised coefficient the inverse transforms are defined for: the dequantisation wraps
/// by design (see [`huge_levels_dequantise_without_overflow_panics`]), so their butterflies
/// clamp an r outside −2^15..2^15−1 to this range — where the spec leaves the output
/// unspecified anyway (8.5.12.1, 8.5.13.1) — instead of overflowing the plain i32 additions.
#[inline(always)]
fn r_clamp(v: i32) -> i32 {
v.clamp(-(1 << 15), (1 << 15) - 1)
}
/// Inverse 4x4 transform of raster-order `d` and add to `dst` (8.5.12.2, 8.5.14).
///
/// Rows first, then columns (the spec's order: the `>> 1` steps make it matter). The column
/// pass and the reconstruction run across all columns at once so they vectorise.
pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
let d: [i32; 16] = std::array::from_fn(|i| r_clamp(d[i]));
let mut t = [[0i32; 4]; 4];
for (i, row) in t.iter_mut().enumerate() {
let r = &d[i * 4..i * 4 + 4];
@@ -161,6 +171,7 @@ fn idct8_1d(d: [i32; 8]) -> [i32; 8] {
/// Inverse 8x8 transform of raster-order `d` and add to `dst` (8.5.13.2): rows, then columns,
/// the column pass and reconstruction across all columns at once (they vectorise).
pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
let d: [i32; 64] = std::array::from_fn(|i| r_clamp(d[i]));
let mut t = [[0i32; 8]; 8];
for (row, src) in t.iter_mut().zip(d.as_chunks::<8>().0) {
*row = idct8_1d(*src);
@@ -304,4 +315,32 @@ mod tests {
let _ = scale8(i32::MIN, 16 * 25, 51);
let _ = scale8(i32::MIN, 16 * 25, 0);
}
/// The same wrapping dequantisation hands the inverse transforms any i32 coefficient, and
/// their butterflies add in plain i32: `i32::MAX + i32::MAX` panicked a debug build. The
/// spec leaves the transforms' output unspecified when a dequantised value falls outside
/// −2^15..2^15−1 (8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
/// bit-exact for every stream inside it — instead of panicking.
#[test]
fn huge_coefficients_are_clamped_to_the_spec_r_range() {
assert_eq!(r_clamp(-32769), -32768);
assert_eq!(r_clamp(-32768), -32768);
assert_eq!(r_clamp(32767), 32767);
assert_eq!(r_clamp(32768), 32767);
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1);
for d in [[i32::MAX; 16], [i32::MIN; 16], std::array::from_fn(|i| if i % 2 == 0 { i32::MIN } else { i32::MAX })] {
let clamped = std::array::from_fn(|i| clamp(d[i]));
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
idct4_add(&d, &mut a, 8);
idct4_add(&clamped, &mut b, 8);
assert_eq!(a, b, "idct4 of extremes equals idct4 of the spec range");
}
for d in [[i32::MIN; 64], [i32::MAX; 64]] {
let clamped = std::array::from_fn(|i| clamp(d[i]));
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
idct8_add(&d, &mut a, 8);
idct8_add(&clamped, &mut b, 8);
assert_eq!(a, b, "idct8 of extremes equals idct8 of the spec range");
}
}
}
+12 -1
View File
@@ -440,11 +440,22 @@ impl Decoder {
let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma);
let next_id = &mut self.next_id;
let mut missing = 0u64;
let max_missing = (sps.max_dec_pic_buffering as u64).clamp(1, 16);
let mut shared_gray: Option<FrameRef> = None;
let mut make_missing = |p: i32| -> FrameRef {
missing += 1;
if missing > max_missing
&& let Some(ref g) = shared_gray
{
return g.clone();
}
let id = *next_id;
*next_id += 1;
Arc::new(Frame::gray(id, p, &template))
let f = Arc::new(Frame::gray(id, p, &template));
if shared_gray.is_none() {
shared_gray = Some(f.clone());
}
f
};
let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?;
if missing > 0 {
+137 -117
View File
@@ -397,6 +397,37 @@ impl<'a> SliceDecoder<'a> {
d.decode_data()
}
/// Context initialisation / synchronisation at the start of a CTB (9.3.1, 9.3.2.1).
fn init_ctb_contexts(&mut self, layout: &Layout, ts: u32, rs: u32, wctb: u32, first: bool) -> Result<()> {
let ctb = 1i32 << self.sps.log2_ctb;
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
let wpp_row_start = self.pps.entropy_coding_sync
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
if first_in_tile {
if !first {
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
}
self.last_qp = self.slice_qp;
} else if wpp_row_start {
let (x0, y0) = (self.ctb_x, self.ctb_y);
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
match (&self.pic.wpp_ctx, avail) {
(Some(saved), true) => self.c.ctx = **saved,
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
}
self.last_qp = self.slice_qp;
} else if first && self.sh.dependent {
match &self.pic.ds_ctx {
Some((saved, qp)) => {
self.c.ctx = **saved;
self.last_qp = *qp;
}
None => return invalid("dependent slice segment without stored contexts"),
}
}
Ok(())
}
fn decode_data(&mut self) -> Result<()> {
let layout = self.pic.layout.clone();
let wctb = self.pic.wctb as u32;
@@ -412,32 +443,7 @@ impl<'a> SliceDecoder<'a> {
self.ctb_y = ry * ctb;
self.pic.ctb_slice[rs as usize] = self.slice_idx;
self.pic.ctb_addr[rs as usize] = self.slice_addr;
// context initialisation / synchronisation (9.3.1, 9.3.2.1)
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
let wpp_row_start = self.pps.entropy_coding_sync
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
if first_in_tile {
if !first {
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
}
self.last_qp = self.slice_qp;
} else if wpp_row_start {
let (x0, y0) = (self.ctb_x, self.ctb_y);
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
match (&self.pic.wpp_ctx, avail) {
(Some(saved), true) => self.c.ctx = **saved,
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
}
self.last_qp = self.slice_qp;
} else if first && self.sh.dependent {
match &self.pic.ds_ctx {
Some((saved, qp)) => {
self.c.ctx = **saved;
self.last_qp = *qp;
}
None => return invalid("dependent slice segment without stored contexts"),
}
}
self.init_ctb_contexts(&layout, ts, rs, wctb, first)?;
first = false;
if self.sh.sao_luma || self.sh.sao_chroma {
self.parse_sao(rx, ry, rs)?;
@@ -506,56 +512,60 @@ impl<'a> SliceDecoder<'a> {
let mut p = SaoParams::default();
for c in 0..3 {
let enabled = if c == 0 { self.sh.sao_luma } else { self.sh.sao_chroma };
if !enabled {
continue;
}
if c == 2 {
p.type_idx[2] = p.type_idx[1];
p.eo_class[2] = p.eo_class[1];
} else {
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
0
} else if self.bypass() == 0 {
1
} else {
2
};
}
if p.type_idx[c] == 0 {
continue;
}
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
let mut abs = [0i16; 4];
for a in abs.iter_mut() {
let mut v = 0;
while v < cmax && self.bypass() == 1 {
v += 1;
}
*a = v as i16;
}
if p.type_idx[c] == 1 {
for a in abs.iter_mut() {
if *a != 0 && self.bypass() == 1 {
*a = -*a;
}
}
p.band_pos[c] = self.bypass_bits(5) as u8;
p.offsets[c] = abs;
} else {
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
if c == 0 {
p.eo_class[0] = self.bypass_bits(2) as u8;
}
if c == 1 {
p.eo_class[1] = self.bypass_bits(2) as u8;
}
if enabled {
self.parse_sao_component(&mut p, c);
}
}
self.pic.sao[rs as usize] = p;
Ok(())
}
/// SAO syntax of one enabled colour component `c` (7.3.8.3).
fn parse_sao_component(&mut self, p: &mut SaoParams, c: usize) {
if c == 2 {
p.type_idx[2] = p.type_idx[1];
p.eo_class[2] = p.eo_class[1];
} else {
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
0
} else if self.bypass() == 0 {
1
} else {
2
};
}
if p.type_idx[c] == 0 {
return;
}
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
let mut abs = [0i16; 4];
for a in abs.iter_mut() {
let mut v = 0;
while v < cmax && self.bypass() == 1 {
v += 1;
}
*a = v as i16;
}
if p.type_idx[c] == 1 {
for a in abs.iter_mut() {
if *a != 0 && self.bypass() == 1 {
*a = -*a;
}
}
p.band_pos[c] = self.bypass_bits(5) as u8;
p.offsets[c] = abs;
} else {
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
if c == 0 {
p.eo_class[0] = self.bypass_bits(2) as u8;
}
if c == 1 {
p.eo_class[1] = self.bypass_bits(2) as u8;
}
}
}
fn coding_quadtree(&mut self, x0: i32, y0: i32, log2: u32, depth: u32) -> Result<()> {
let size = 1i32 << log2;
let (w, h) = (self.pic.width as i32, self.pic.height as i32);
@@ -826,6 +836,34 @@ impl<'a> SliceDecoder<'a> {
self.c.init_engine()
}
/// Most probable luma intra modes of the block at (xp, yp) (8.4.2).
fn mpm_list(&self, xp: i32, yp: i32) -> [u32; 3] {
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
let b = self.pic.blk_at(xp - 1, yp);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
let b = self.pic.blk_at(xp, yp - 1);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
if cand_a == cand_b {
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
} else {
let c = if cand_a != 0 && cand_b != 0 {
0
} else if cand_a != 1 && cand_b != 1 {
1
} else {
26
};
[cand_a, cand_b, c]
}
}
fn parse_intra_modes(&mut self, x0: i32, y0: i32, n: i32, part: PartMode) -> Result<()> {
let nb = if part == PartMode::PNxN { 2 } else { 1 };
let pb = n / nb;
@@ -838,31 +876,7 @@ impl<'a> SliceDecoder<'a> {
for i in 0..nb {
let k = (j * nb + i) as usize;
let (xp, yp) = (x0 + i * pb, y0 + j * pb);
// candidate modes (8.4.2)
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
let b = self.pic.blk_at(xp - 1, yp);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
let b = self.pic.blk_at(xp, yp - 1);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let mut list = if cand_a == cand_b {
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
} else {
let c = if cand_a != 0 && cand_b != 0 {
0
} else if cand_a != 1 && cand_b != 1 {
1
} else {
26
};
[cand_a, cand_b, c]
};
let mut list = self.mpm_list(xp, yp);
let mode = if prev[k] {
let idx = if self.bypass() == 0 {
0
@@ -938,33 +952,39 @@ impl<'a> SliceDecoder<'a> {
self.transform_unit(x0, y0, xb, yb, log2, blk_idx, cbf_luma, cbf_c)
}
/// cu_qp_delta_abs / sign and QP update (7.3.8.14).
fn parse_cu_qp_delta(&mut self) -> Result<()> {
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
let mut v = 0;
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
v += 1;
}
if v == 5 {
let mut k = 0;
while self.bypass() == 1 {
v += 1 << k;
k += 1;
ensure!(k < 32, "invalid cu_qp_delta_abs");
}
v += self.bypass_bits(k) as i32;
}
if v != 0 && self.bypass() == 1 {
v = -v;
}
let off = 6 * (self.pic.bd_y as i32 - 8);
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
self.is_cu_qp_delta_coded = true;
self.cu_qp_delta = v;
self.update_qp();
Ok(())
}
fn transform_unit(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, blk_idx: u32, cbf_luma: bool, cbf_c: [bool; 2]) -> Result<()> {
let n = 1i32 << log2;
let chroma_here = log2 > 2 || blk_idx == 3;
let cbf_chroma = cbf_c[0] || cbf_c[1];
if (cbf_luma || cbf_chroma) && self.pps.cu_qp_delta_enabled && !self.is_cu_qp_delta_coded {
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
let mut v = 0;
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
v += 1;
}
if v == 5 {
let mut k = 0;
while self.bypass() == 1 {
v += 1 << k;
k += 1;
ensure!(k < 32, "invalid cu_qp_delta_abs");
}
v += self.bypass_bits(k) as i32;
}
if v != 0 && self.bypass() == 1 {
v = -v;
}
let off = 6 * (self.pic.bd_y as i32 - 8);
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
self.is_cu_qp_delta_coded = true;
self.cu_qp_delta = v;
self.update_qp();
self.parse_cu_qp_delta()?;
}
// luma
if self.cu_intra {
+18 -2
View File
@@ -24,6 +24,8 @@ pub struct Pcm {
pub samples: Vec<f32>,
}
const MAX_PCM_SAMPLES: usize = 64 * 1024 * 1024;
impl Pcm {
/// Sample frames (samples per channel).
pub fn frames(&self) -> usize {
@@ -47,8 +49,10 @@ impl Pcm {
fn push_planar(planes: &[Vec<f32>], out: &mut Vec<f32>) {
let n = planes.iter().map(Vec::len).min().unwrap_or(0);
out.reserve(n * planes.len());
for i in 0..n {
let remaining = MAX_PCM_SAMPLES.saturating_sub(out.len());
let to_push = n.min(remaining / planes.len().max(1));
out.reserve(to_push * planes.len());
for i in 0..to_push {
for p in planes {
out.push(p[i]);
}
@@ -96,7 +100,13 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut out = Vec::new();
for i in 0..stream.packets.len() {
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
break;
}
let Ok(p) = file.read_packet(src, s, i) else { continue };
if p.is_empty() {
continue;
}
match dec.decode(Some(&p)) {
Ok(planes) => Pcm::push_planar(&planes, &mut out),
Err(e) => log::debug!("opus packet {i}: {e}"),
@@ -126,7 +136,13 @@ fn decode_mkv_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let mut out = Vec::new();
let n = file.tracks.get(ti).map(|t| t.samples.len()).unwrap_or(0);
for i in 0..n {
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
break;
}
let Ok(p) = file.read_sample(src, ti, i) else { continue };
if p.is_empty() {
continue;
}
match dec.decode(Some(&p)) {
Ok(planes) => Pcm::push_planar(&planes, &mut out),
Err(e) => log::debug!("opus packet {i}: {e}"),
Binary file not shown.
+1
View File
@@ -37,4 +37,5 @@ $FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -sho
$FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov
$FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4
$FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm
$FF $VID $TONE44 -c:v libvpx -pix_fmt yuv420p -g 5 -b:v 100k -deadline realtime -c:a vorbis -strict -2 -ac 2 -b:a 48k -shortest $META clip-vp8.webm
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
+1566 -1566
View File
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
ColorRef, Fill, LayoutType, Paragraph, PhType, Placeholder, Presentation, Run, RunProps, SchemeSlot, Shape, ShapeId, ShapeKind, ShapeStyle,
Slide, Table, TextBody, Theme, Xfrm, defaults, ink_pressure_is_legacy, ink_sample_width, parent_of, resolve, sibling_list_mut, style, text,
theme, walk,
};
use crate::resolve::Ctx;
#[test]
@@ -294,3 +298,27 @@ fn table_text_takes_the_masters_other_style_and_shapes_the_default_text_style()
assert_eq!(pt(&Shape { text: Some(body.clone()), ..Default::default() }), Some(26.0), "a shape");
assert_eq!(pt(&Shape { text: Some(body), text_box: true, ..Default::default() }), Some(26.0), "a text box");
}
#[test]
fn only_web_and_mail_links_are_safe_to_open() {
use crate::text::safe_url;
for url in ["https://example.com", " HTTP://example.com/x", "mailto:someone@example.com"] {
assert!(safe_url(url), "{url}");
}
let refused = [
"https://",
"mailto:",
"file:///C:/notes.pdf",
"notes.pdf",
"javascript:alert(1)",
"ms-msdt:/id PCWDiagnostic",
"ldap://attacker.example/x",
"//attacker.example/share",
"\\\\attacker.example\\share",
"smb://attacker.example/share",
"search-ms:query=x",
];
for url in refused {
assert!(!safe_url(url), "{url}");
}
}
+13
View File
@@ -328,6 +328,19 @@ pub enum Action {
PlayMedia,
}
/// Can a link from a (possibly untrusted) deck be opened outside it, by the slide show or by
/// whoever opens an exported file? Only web pages and mail: `file:`, `javascript:`, relative paths
/// and other schemes (OS protocol handlers, network shares) are not.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
fn custom_show_returns() -> bool {
true
}
+57 -10
View File
@@ -241,26 +241,28 @@ fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> Option<(f64, f64)>
sxx += x * x;
sxy += x * (y - b);
}
if sxx.abs() < 1e-18 {
if sxx <= 0.0 || !sxx.is_finite() {
return None;
}
let m = sxy / sxx;
return (m.is_finite() && b.is_finite()).then_some((b, m));
}
let n = xs.len() as f64;
let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0);
let mx = xs.iter().sum::<f64>() / n;
let my = ys.iter().sum::<f64>() / n;
let (mut sxx, mut sxy) = (0.0, 0.0);
for (x, y) in xs.iter().zip(ys) {
sx += x;
sy += y;
sxx += x * x;
sxy += x * y;
let dx = x - mx;
sxx += dx * dx;
sxy += dx * (y - my);
}
let den = n * sxx - sx * sx;
if den.abs() < 1e-18 {
// Constant x with a large offset leaves rounding residue in `mx`, so compare against the
// rounding scale of the mean instead of zero.
if sxx <= 4.0 * n * (f64::EPSILON * mx.abs()).powi(2) || !sxx.is_finite() {
return None;
}
let m = (n * sxy - sx * sy) / den;
let b = (sy - m * sx) / n;
let m = sxy / sxx;
let b = my - m * mx;
(m.is_finite() && b.is_finite()).then_some((b, m))
}
@@ -561,6 +563,51 @@ mod tests {
Trendline { kind, ..Default::default() }
}
#[test]
fn linear_fits_tiny_x_values() {
let xs = [1e-10, 2e-10, 3e-10, 4e-10, 5e-10];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of tiny x values");
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
}
#[test]
fn linear_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of offset x values");
assert!((fit.y(45000.02).unwrap() - 3.0).abs() < 1e-4);
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of 1e7 offset");
assert!((fit.y(1e7 + 2.0).unwrap() - 3.0).abs() < 1e-4);
}
#[test]
fn linear_fixed_intercept_on_offset_narrow_x_range() {
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x).collect();
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
let fit = forced.fit(&xs, &ys).expect("fixed-intercept fit");
assert!((fit.y(1e7 + 2.0).unwrap() / (2.0 * (1e7 + 2.0)) - 1.0).abs() < 1e-9);
}
#[test]
fn linear_fixed_intercept_rejects_overflowing_x() {
let xs = [1e160, 2e160, 3e160];
let ys = [1.0, 2.0, 3.0];
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
assert!(forced.fit(&xs, &ys).is_none());
}
#[test]
fn linear_rejects_constant_x_with_large_offset() {
let xs = [45000.1; 5];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
assert!(line(TrendlineType::Linear).fit(&xs, &ys).is_none());
}
#[test]
fn linear_recovers_slope_and_a_fixed_intercept() {
let xs = [1.0, 2.0, 3.0, 4.0];
+41 -14
View File
@@ -212,10 +212,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
let mut pos = 0u64;
let mut found_any = false;
let mut hdr = [0u8; 27 + 255];
let mut scanner = CaptureScanner::new();
while pos + 27 <= len {
src.read_at(pos, &mut hdr[..27])?;
if &hdr[..4] != b"OggS" || hdr[4] != 0 {
match find_capture(src, pos + 1, len)? {
match scanner.find(src, pos + 1, len)? {
Some(p) => {
if found_any {
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
@@ -336,21 +337,47 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
Ok(OggFile { streams, warnings })
}
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
let mut at = from;
while at + 4 <= len {
let n = ((len - at) as usize).min(1 << 20);
let mut buf = vec![0u8; n];
src.read_at(at, &mut buf)?;
if let Some(i) = buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
struct CaptureScanner {
buf: Vec<u8>,
buf_start: u64,
budget: usize,
}
impl CaptureScanner {
fn new() -> Self {
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
}
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
let mut at = from;
while at + 4 <= len {
let buf_end = self.buf_start + self.buf.len() as u64;
if at >= self.buf_start && at + 4 <= buf_end {
let off = (at - self.buf_start) as usize;
if let Some(i) = self.buf[off..].windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
}
at = buf_end.saturating_sub(3);
continue;
}
if self.budget == 0 {
return Ok(None);
}
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
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);
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
}
at += (n - 3) as u64;
}
if n < 4 {
break;
}
at += (n - 3) as u64;
Ok(None)
}
Ok(None)
}
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
+1 -1
View File
@@ -1,6 +1,6 @@
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
use super::*;
use super::{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]) {
+4 -2
View File
@@ -17,7 +17,7 @@ use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
use deckcraft_fonts::FontDb;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action as ClickAction;
use deckcraft_model::text::{Action as ClickAction, safe_url};
use deckcraft_render::{PlacedText, RenderOpts};
use krilla::action::{Action, LinkAction};
use krilla::annotation::{Annotation, LinkAnnotation, Target};
@@ -565,7 +565,9 @@ impl Exporter<'_> {
slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into()))
};
let target = match &l.link.action {
ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
// Only the links the slide show opens (web and mail): a reader of the file gets the rest
// as plain text.
ClickAction::Url { url } if safe_url(url) => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)),
ClickAction::NextSlide => goto(Some(i + 1)),
ClickAction::PreviousSlide => goto(i.checked_sub(1)),
+69 -1
View File
@@ -1,6 +1,15 @@
use std::sync::Arc;
use super::*;
use std::collections::HashMap;
use deckcraft_fonts::FontDb;
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, paper_size, raster_scale,
};
use deckcraft_model::text::TextBody;
fn deck(n: usize) -> Presentation {
@@ -130,6 +139,65 @@ fn text_layer_links_and_bookmarks_survive_serialization() {
}
}
/// Links from an untrusted deck: only web and mail links become PDF links. Scripts, `data:`,
/// local and relative files, OS protocol handlers and network shares (a UNC path leaks the user's
/// NTLM credentials on Windows) do not.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unsafe_url_links_are_not_exported() {
use deckcraft_model::text::{Action, Hyperlink};
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::object::{Array, Dict, String as PdfString};
let safe = ["https://example.com/deck", "mailto:someone@example.com"];
let refused = [
"file:///C:/talks/notes.pdf",
"notes.pdf",
"ms-msdt:/id PCWDiagnostic /skip force",
"ms-word:ofe|u|\\\\attacker.example\\share\\x.docx",
"ldap://attacker.example/x",
"file:///%5C%5Cattacker.example/share/x",
"javascript:alert(1)",
"\t JavaScript:alert(1)",
"java\nscript:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"\\\\attacker.example\\share\\x",
"//attacker.example/share/x",
"file://attacker.example/share/x",
"file:////attacker.example/share/x",
"smb://attacker.example/share/x",
"search-ms:query=x&crumb=location:\\\\attacker.example\\share",
"search:query=x&crumb=location:\\\\attacker.example\\share",
];
let mut p = deck(1);
let slide = Arc::make_mut(&mut p.slides[0]);
slide.shapes = (0..)
.zip(safe.iter().chain(&refused))
.map(|(i, url)| Shape {
id: ShapeId(9000 + i),
xfrm: Some(Xfrm::new(20.0, 20.0 + 32.0 * f64::from(i), 400.0, 26.0)),
click: Some(Hyperlink {
action: Action::Url { url: url.to_string() },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
})
.collect();
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let annotations = pdf.pages()[0].raw().get::<Array<'_>>(b"Annots").unwrap();
let uris: Vec<String> = annotations
.iter::<Dict<'_>>()
.filter_map(|a| a.get::<Dict<'_>>(b"A")?.get::<PdfString<'_>>(b"URI"))
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
.collect();
assert_eq!(uris, safe);
}
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures.
+18 -2
View File
@@ -279,7 +279,11 @@ fn percent_decode(s: &str) -> String {
while i < b.len() {
let c = b.get(i).copied().unwrap_or(0);
if c == b'%'
&& let Some(h) = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
&& let Some(h) = b
.get(i + 1..i + 3)
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(h);
i += 3;
@@ -293,7 +297,7 @@ fn percent_decode(s: &str) -> String {
/// Resolve a relative target against a base directory into a part name.
pub fn resolve(base: &str, target: &str) -> String {
let target = percent_decode(target.split('#').next().unwrap_or(""));
let target = percent_decode(target.split('#').next().unwrap_or("")).replace('\\', "/");
let target = target.as_str();
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
for s in target.split('/') {
@@ -464,6 +468,12 @@ mod tests {
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
}
#[test]
fn resolve_percent_decode_requires_two_hex_digits() {
assert_eq!(resolve("ppt/slides", "../media/a%+5b.png"), "ppt/media/a%+5b.png");
assert_eq!(resolve("ppt/slides", "../media/a%20b.png"), "ppt/media/a b.png");
}
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
@@ -497,6 +507,12 @@ mod tests {
assert!(p.xml_bytes("missing.xml").is_none());
}
#[test]
fn resolve_backslash_target() {
assert_eq!(resolve("ppt/slides", "..\\media\\image1.png"), "ppt/media/image1.png");
assert_eq!(resolve("ppt/slides", "\\ppt\\media\\a.png"), "ppt/media/a.png");
}
#[test]
fn path_resolution() {
assert_eq!(resolve("ppt/slides", "../media/image1.png"), "ppt/media/image1.png");
+11
View File
@@ -447,6 +447,17 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
}
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
// An embedded object's fallback picture (`p:oleObj/p:pic`) can carry a fill and an outline,
// which PowerPoint draws behind and around the preview (old equation objects on coloured
// boxes). The frame's own position stays.
if let Some(pr) = gd.and_then(|g| g.find("oleObj")).and_then(|o| o.find("pic")).and_then(|p| p.child("spPr")) {
if s.fill.is_none() {
s.fill = dml::fill_of(imp, part, pr);
}
if s.line.is_none() {
s.line = pr.child("ln").map(|ln| dml::line(imp, part, ln));
}
}
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
} else if uri.ends_with("/diagram") {
+5 -1
View File
@@ -909,8 +909,12 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
w.empty0("p:nvPr");
w.close("p:nvPicPr");
dml::blip_fill(w, x, o, "p:blipFill", fill);
// As for any picture, a colour or pattern fill shows behind the image (an object's preview
// keeps its fallback picture's fill).
let mut s2 = s.clone();
s2.fill = None;
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
s2.fill = None;
}
sp_pr(w, x, o, &s2, true);
w.close("p:pic");
} else {
+51
View File
@@ -96,3 +96,54 @@ fn an_old_ole_object_shows_its_vml_preview() {
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
}
/// PowerPoint 2010 and later: `p:oleObj` with a fallback `p:pic` inside `mc:AlternateContent`, whose
/// `spPr` can give the object a fill and an outline (old equation objects on coloured boxes).
#[test]
fn an_ole_objects_fallback_picture_gives_it_its_fill_and_outline() {
let pic = |spr: &str| {
format!(
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/></p:oleObj></mc:Choice><mc:Fallback><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/><p:pic><p:nvPicPr><p:cNvPr id="0" name="Object 3"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>{spr}</p:spPr></p:pic></p:oleObj></mc:Fallback></mc:AlternateContent></a:graphicData></a:graphic></p:graphicFrame>"#
)
};
let deck = |frame: String| {
with_parts(&[("ppt/media/eq.png", png()), ("ppt/embeddings/oleObject1.bin", vec![0; 16])], move |name, s| {
if name == "ppt/slides/slide1.xml" {
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
s.replacen(
"</Relationships>",
r#"<Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/eq.png"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
1,
)
} else {
s
}
})
};
let object = |bytes: Vec<u8>| {
let p = deckcraft_pptx::import(&bytes).expect("import");
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object").clone();
assert!(matches!(&s.kind, ShapeKind::Opaque { preview: Some(_), label, .. } if label == "Equation.3"), "{:?}", s.kind);
s
};
let filled = object(deck(pic(
r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill><a:ln w="12700"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln>"#,
)));
let orange = deckcraft_model::style::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xFF, 0x99, 0)));
assert_eq!(filled.fill, Some(orange.clone()));
assert_eq!(filled.line.as_ref().and_then(|l| l.width), Some(1.0));
// Saved as its preview picture (its kept XML can't be written back), it keeps the fill.
let mut p = deckcraft_pptx::import(&deck(pic(r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill>"#))).expect("import");
for s in &mut std::sync::Arc::make_mut(&mut p.slides[0]).shapes {
if let ShapeKind::Opaque { xml, .. } = &mut s.kind {
xml.clear();
}
}
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
let saved = back.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
assert!(matches!(saved.kind, ShapeKind::Picture { .. }), "{:?}", saved.kind);
assert_eq!(saved.fill, Some(orange));
// Without a fill of its own the object stays unfilled.
assert_eq!(object(deck(pic(""))).fill, None);
}
+37 -10
View File
@@ -170,19 +170,37 @@ pub fn squarify(vals: &[f64], r: Rect) -> Vec<Rect> {
let mut i = 0;
while i < order.len() {
let short = free.width().min(free.height());
let worst = |row: &[usize]| {
let areas: Vec<f64> = row.iter().map(|&k| vals[k] * scale).collect();
let sum: f64 = areas.iter().sum();
let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min));
((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn))
};
if !short.is_finite() || short <= 1e-9 {
break;
}
let s2 = short * short;
let a0 = vals[order[i]] * scale;
let mut cur_sum = a0;
let mut cur_max = a0;
let mut cur_min = a0;
let mut cur_worst = ((s2 * cur_max) / (cur_sum * cur_sum)).max((cur_sum * cur_sum) / (s2 * cur_min));
let mut end = i + 1;
while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) {
end += 1;
while end < order.len() {
let next_a = vals[order[end]] * scale;
let cand_sum = cur_sum + next_a;
let cand_max = cur_max.max(next_a);
let cand_min = cur_min.min(next_a);
let cand_worst = ((s2 * cand_max) / (cand_sum * cand_sum)).max((cand_sum * cand_sum) / (s2 * cand_min));
if cand_worst.is_finite() && cand_worst <= cur_worst {
cur_sum = cand_sum;
cur_max = cand_max;
cur_min = cand_min;
cur_worst = cand_worst;
end += 1;
} else {
break;
}
}
let row = &order[i..end];
let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum();
let thick = sum / short;
let thick = cur_sum / short;
if !thick.is_finite() || thick <= 0.0 {
break;
}
let mut at = 0.0;
for &k in row {
let len = vals[k] * scale / thick;
@@ -570,4 +588,13 @@ mod tests {
assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]);
assert_eq!(c.value_range(), (0.0, 140.0));
}
#[test]
fn squarify_handles_skewed_and_exhausted_rect() {
let vals = vec![1e12, 1e-6, 1e-6, 1e-6];
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
let tiles = squarify(&vals, r);
assert_eq!(tiles.len(), vals.len());
assert!(tiles[0].area() > 0.0);
}
}
+12 -2
View File
@@ -47,6 +47,7 @@ const GIF_SIDE: u32 = 1024;
const GIF_BUDGET: usize = 64 << 20;
const GIF_MAX_FRAMES: usize = 1000;
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
const GIF_MAX_TOTAL_CANVAS_PIXELS: u64 = 256 * 1024 * 1024;
const GIF_CACHE_BUDGET: usize = 192 << 20;
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
@@ -394,13 +395,22 @@ fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
let size = nw as usize * nh as usize * 4;
let canvas_pixels = (w as u64) * (h as u64);
let mut total_canvas_pixels: u64 = 0;
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
for f in dec.into_frames() {
let Ok(f) = f else { break };
let mut iter = dec.into_frames();
loop {
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
log::info!("animated GIF: keeping its first {} frames", frames.len());
break;
}
if total_canvas_pixels.saturating_add(canvas_pixels) > GIF_MAX_TOTAL_CANVAS_PIXELS {
log::info!("animated GIF: canvas pixel limit reached");
break;
}
let Some(f) = iter.next() else { break };
let Ok(f) = f else { break };
total_canvas_pixels = total_canvas_pixels.saturating_add(canvas_pixels);
let (n, d) = f.delay().numer_denom_ms();
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
// Like browsers: frames of 10 ms or less show for 100 ms.
+116 -33
View File
@@ -321,6 +321,13 @@ pub struct Renderer {
group_fill: Option<(Fill, Affine, Rect)>,
/// Their aligned outlines, kept from frame to frame.
morphs: morph_path::Cache,
/// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group.
in_shadow_pass: bool,
/// Cumulative shape traversal counter to prevent nested group amplification DoS.
render_work: usize,
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows.
shadow_work: usize,
}
impl Default for Renderer {
@@ -333,6 +340,9 @@ impl Default for Renderer {
view: Affine::IDENTITY,
group_fill: None,
morphs: morph_path::Cache::default(),
in_shadow_pass: false,
render_work: 0,
shadow_work: 0,
}
}
}
@@ -678,6 +688,9 @@ impl Renderer {
}
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
if let Some(rctx) = Ctx::for_slide(pres, slide) {
@@ -690,6 +703,9 @@ impl Renderer {
}
fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
if let Some(rctx) = Ctx::for_slide(pres, slide) {
@@ -721,6 +737,9 @@ impl Renderer {
opts: &RenderOpts,
) -> Image {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0;
self.shadow_work = 0;
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.
@@ -768,6 +787,9 @@ impl Renderer {
}
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
@@ -896,6 +918,11 @@ impl Renderer {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return;
}
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
*work = work.saturating_add(1);
if *work > 100_000 {
return;
}
// Footer placeholders on layouts/masters show only on slides that include them.
let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();
if !st.visible || st.opacity <= 0.001 {
@@ -936,7 +963,9 @@ impl Renderer {
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
// children drawn once more under the shadow's offset, scale and skew, recoloured to
// the shadow colour and blurred, below the group.
if let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) {
if !self.in_shadow_pass
&& let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref())
{
let c = rctx.color(&sh.color, None);
let k = scale_of(m);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
@@ -948,9 +977,11 @@ impl Renderer {
let shift = pre * m * own * m.inverse();
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
self.in_shadow_pass = true;
for ch in children {
self.shape(ctx, f, rctx, ch, shift * gm, depth + 1);
}
self.in_shadow_pass = false;
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
ctx.set_paint(color(c.with_alpha(255), 1.0));
@@ -1025,6 +1056,51 @@ impl Renderer {
}
}
/// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per
/// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill`
/// the group's fill.
#[allow(clippy::too_many_arguments)]
fn fill_geometry(
&self,
ctx: &mut RenderContext,
f: &Frame,
rctx: &Ctx,
fl: &Fill,
fill_ph: Option<Rgba>,
geo: &Geometry,
(w, h): (f64, f64),
m: Affine,
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
) {
for (i, sp) in geo.paths.iter().enumerate() {
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
continue;
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
// `grpFill`: the group's fill, laid out over the whole group.
(Fill::Group, _) => {
if let Some((gf, gm, gbox)) = &self.group_fill
&& gm.determinant().abs() > 1e-12
{
let path = (gm.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
}
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
#[allow(clippy::too_many_arguments)]
fn geometry_shape(
&mut self,
@@ -1121,6 +1197,13 @@ impl Renderer {
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
// The picture's own fill shows behind its transparent pixels, as in PowerPoint (an
// emphasis tint recolours it, as it does any shape's fill).
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
@@ -1131,6 +1214,13 @@ impl Renderer {
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
// preview picture, stretched over the frame, as PowerPoint draws it.
ShapeKind::Opaque { preview: Some(media), .. } => {
// An embedded object's fallback picture can carry a fill (old equation objects
// drawn on coloured boxes): behind the preview, as for a picture.
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
@@ -1155,33 +1245,7 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
{
for (i, sp) in geo.paths.iter().enumerate() {
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
continue;
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
// `grpFill`: the group's fill, laid out over the whole group.
(Fill::Group, _) => {
if let Some((gf, gm, gbox)) = &self.group_fill
&& gm.determinant().abs() > 1e-12
{
let path = (gm.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
}
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
}
}
@@ -1925,16 +1989,21 @@ fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath {
&& start > 0.0
{
let mut left = start;
while let Some(seg) = segs.first().copied() {
let mut drop_count = 0;
for (i, seg) in segs.iter().enumerate() {
let l = seg.arclen(ACC);
if l > left {
if let Some(s0) = segs.first_mut() {
*s0 = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
}
let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
segs.drain(..i);
segs[0] = partial;
drop_count = 0;
break;
}
left -= l;
segs.remove(0);
drop_count = i + 1;
}
if drop_count > 0 {
segs.drain(..drop_count);
}
}
if let Some((segs, false)) = subs.last_mut()
@@ -2243,6 +2312,20 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
}
let end = (first + count).min(l.lines.len());
if first < end {
return l.lines[first..end]
.iter()
.enumerate()
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| first + i)
.unwrap_or(0);
}
}
l.lines
.iter()
.enumerate()
+27 -15
View File
@@ -138,8 +138,17 @@ fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
}
}
pub fn resolve_style<'a>(ctx: &'a Ctx, t: &Table) -> Option<&'a TableStyleDef> {
ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style))
}
#[allow(dead_code)]
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
look_with_def(ctx, resolve_style(ctx, t), t, r, c)
}
pub fn look_with_def(ctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = def {
return look_def(ctx, def, t, r, c);
}
let a = accent(ctx, &t.style);
@@ -242,7 +251,11 @@ fn edges(sizes: &[f64]) -> Vec<f64> {
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look(rctx, t, r, c);
cell_body_with_def(rctx, resolve_style(rctx, t), t, r, c, cell)
}
pub fn cell_body_with_def(rctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look_with_def(rctx, def, t, r, c);
let mut body = cell.text.clone();
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
body.body.inset_l = Some(ml);
@@ -272,6 +285,11 @@ pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> Text
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
/// Cells spanning several rows and vertical text keep the stored height.
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
row_heights_with_def(rctx, resolve_style(rctx, t), t, fields)
}
/// [`row_heights`] with the table's style already resolved, for a caller that needs it too.
fn row_heights_with_def(rctx: &Ctx, style_def: Option<&TableStyleDef>, t: &Table, fields: &dyn Fields) -> Vec<f64> {
let xs = edges(&t.cols);
let mut out = Vec::with_capacity(t.rows.len());
for (ri, row) in t.rows.iter().enumerate() {
@@ -282,7 +300,7 @@ pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
}
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
let body = cell_body(rctx, t, ri, ci, cell);
let body = cell_body_with_def(rctx, style_def, t, ri, ci, cell);
let tmp = Shape::table_cell(body.clone());
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
if need.is_finite() {
@@ -347,16 +365,10 @@ pub fn draw(
editing: bool,
) -> Option<kurbo::Rect> {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
let style_def = resolve_style(rctx, t);
let rects = cell_rects(t, &row_heights_with_def(rctx, style_def, t, fields));
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
.table_styles
.iter()
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
.and_then(|d| d.background.as_ref())
.and_then(|b| style_fill(rctx, b));
let style_bg = style_def.and_then(|d| d.background.as_ref()).and_then(|b| style_fill(rctx, b));
let own_bg = t.background.clone().map(|f| (f, None));
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
@@ -366,7 +378,7 @@ pub fn draw(
}
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
let lk = look_with_def(rctx, style_def, t, *r, *c);
ctx.set_transform(m);
match &cell.fill {
Some(f) => {
@@ -399,7 +411,7 @@ pub fn draw(
// Borders: explicit cell borders, else the style's grid.
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
let lk = look_with_def(rctx, style_def, t, *r, *c);
let edges = [
(rect.x0, rect.y0, rect.x0, rect.y1),
(rect.x1, rect.y0, rect.x1, rect.y1),
@@ -439,7 +451,7 @@ pub fn draw(
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
continue;
}
let body = cell_body(rctx, t, *r, *c, cell);
let body = cell_body_with_def(rctx, style_def, t, *r, *c, cell);
let tmp = Shape::table_cell(body.clone());
#[cfg(test)]
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
+41 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
use deckcraft_model::text::TextBody;
@@ -2009,6 +2013,42 @@ fn group_child_scale_does_not_scale_line_widths() {
}
}
#[test]
fn a_picture_and_an_embedded_objects_preview_draw_their_fill_behind_transparent_pixels() {
// PowerPoint draws a picture's own fill behind its transparent pixels (and an old equation
// object's fallback picture on its coloured box). Here the left half of the image is clear.
let png = {
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 20, 160, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let orange = Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 153, 0)));
for (object, fill) in [(false, None), (false, Some(orange.clone())), (true, Some(orange.clone()))] {
let mut p = Presentation::default();
let media = p.add_media("half.png", "image/png", png.clone()).expect("media");
let kind = if object {
ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "Equation.3".into(), parts: vec![] }
} else {
ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }
};
let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind, fill: fill.clone(), ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (clear, opaque) = (img.pixel(200, 200), img.pixel(400, 200));
assert!(opaque[2] > 140 && opaque[0] < 40, "the picture's own pixels ({object}): {opaque:?}");
if fill.is_some() {
assert_eq!(clear[..3], [255, 153, 0], "the fill behind the clear half ({object})");
// A fill-colour emphasis recolours that fill, as it does any shape's.
let tint = |_: ShapeId| ShapeState { tint: Some(deckcraft_color::Rgba::rgb(0, 160, 60)), ..Default::default() };
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&tint), ..Default::default() });
assert_eq!(img.pixel(200, 200)[..3], [0, 160, 60], "the tinted fill behind the clear half ({object})");
} else {
assert!(clear[..3].iter().all(|v| *v > 240), "no fill: the slide shows through: {clear:?}");
}
}
}
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
+14 -1
View File
@@ -1,10 +1,14 @@
//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports
//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`).
use super::*;
use super::{
BezPath, Fill, Image, Line, MAX_DASHES, PathEl, Point, Rect, RenderOpts, ShapeKind, dash_with_caps, head_size, images, limit_dash_count, paint,
render_slide, trim_ends,
};
use deckcraft_color::Rgba;
use deckcraft_model::style::ColorRef;
use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm};
use kurbo::Shape as _;
fn deck_with(shapes: Vec<Shape>) -> Presentation {
let mut p = Presentation::default();
@@ -160,6 +164,15 @@ fn trimming_a_path_leaves_its_ends_off_by_arc_length() {
assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty());
let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len());
// Zero-length leading segments trimmed correctly without quadratic copy.
let mut p0 = BezPath::new();
p0.move_to((0.0, 0.0));
p0.line_to((0.0, 0.0));
p0.line_to((0.0, 0.0));
p0.line_to((50.0, 0.0));
let t0 = trim_ends(&p0, 10.0, 0.0);
assert_eq!(t0.elements().first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0))));
}
fn radial(focus: [f64; 4]) -> Shape {
+17
View File
@@ -16,6 +16,23 @@ const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return None;
}
let end = (first + count).min(l.lines.len());
if first < end {
let mut best: Option<(usize, f64)> = None;
for (rel_i, li) in l.lines[first..end].iter().enumerate() {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((first + rel_i, d));
}
}
return best.map(|(i, _)| i);
}
}
let mut best: Option<(usize, f64)> = None;
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
+6 -1
View File
@@ -1,4 +1,9 @@
use super::*;
use super::{
Affinity, Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Cell, Ctx, Dash, Deco, DecoLine, FontDb, FontFace, GlyphRun, LINE_PITCH,
MoveMode, NoFields, Opts, ParaProps, Point, Pos, Rect, Rgba, SUB_SUPER, Shape, Spacing, Strike, TextBody, TextLayout, autonum,
break_opportunities, caps_char, chinese_num, covers_symbol, is_symbol_font, is_upright, layout, layout_scaled, map_symbol_bullet, measure_str,
resolve, shaping_features, symbol_char, symbol_font_char, symbol_font_draw, symbol_unicode, symbol_unicode_alts,
};
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
+7 -3
View File
@@ -18,15 +18,19 @@ deckcraft-render = { workspace = true }
deckcraft-anim = { workspace = true }
deckcraft-engine = { workspace = true }
deckcraft-media = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
[dev-dependencies]
skrifa = { workspace = true }
# kittest's own dev-dependencies do not propagate; tests need both font features explicitly.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[build-dependencies]
+65 -38
View File
@@ -153,7 +153,7 @@ pub enum ExportTask {
pub struct Backstage {
pub page: Page,
/// The open presentations (count, active) when it opened: a change closes it.
pub docs: (usize, Option<usize>),
pub docs: (usize, Option<u64>),
/// Keyboard focus in the side menu (an index into [`entries`], top then bottom).
pub focus: Option<usize>,
/// Search text on Home, New and Open.
@@ -180,7 +180,9 @@ pub struct Backstage {
}
impl Backstage {
pub fn new(page: Page, docs: (usize, Option<usize>)) -> Backstage {
/// Construct the Backstage view at a page with the current document identity, so a replaced deck
/// can be detected.
pub fn new(page: Page, docs: (usize, Option<u64>)) -> Backstage {
Backstage {
page,
docs,
@@ -337,7 +339,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
ui.spacing_mut().item_spacing.y = 0.0;
if has_doc {
let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Back"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back"));
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover);
@@ -437,11 +439,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
// ---------------------------------------------------------------- side menu
/// Draw one clickable Backstage navigation row and return its interaction response.
fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click());
let selected = nav == Nav::Page(bs.page);
let label = nav.label();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
let p = ui.painter();
let row = rect.shrink2(vec2(6.0, 1.0));
if resp.hovered() {
@@ -495,7 +498,7 @@ fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: b
let t = Tokens::get(ui.ctx());
let thumb_w = if big { 200.0 } else { 160.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, CornerRadius::same(4), t.hover);
@@ -599,7 +602,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
let missing = !app.recent_exists(&path);
let (name, folder) = split_path(&path);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, CornerRadius::same(3), t.hover);
@@ -625,7 +628,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)),
Sense::click(),
);
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent"));
x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent"));
if x.hovered() {
ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover);
}
@@ -643,7 +646,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
let p = ui.painter();
p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card });
p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside);
@@ -658,7 +661,7 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::R
fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
let p = ui.painter();
if selected {
p.rect_filled(rect, CornerRadius::same(2), t.selected);
@@ -936,13 +939,18 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
ui.add_space(14.0);
let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1);
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("backstage_save_type").width(340.0).selected_text(current).show_ui(ui, |ui| {
for (id, label, _) in SAVE_TYPES {
if ui.selectable_label(bs.file_type == id, label).clicked() {
bs.file_type = id;
egui::ComboBox::from_id_salt("backstage_save_type")
.width(340.0)
.selected_text(current)
.show_ui(ui, |ui| {
for (id, label, _) in SAVE_TYPES {
if ui.selectable_label(bs.file_type == id, label).clicked() {
bs.file_type = id;
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
let web = app.services.pick_save.is_none();
let label = if web { "Download" } else { "Save" };
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
@@ -1057,7 +1065,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
PrintScope::Current => "Print Current Slide",
PrintScope::Custom => "Custom Range",
};
egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| {
for (s, label) in
[(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")]
{
@@ -1067,6 +1075,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range"));
ui.add_space(4.0);
ui.horizontal(|ui| {
let l = ui.label("Slides:");
@@ -1079,7 +1088,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
});
ui.add_space(4.0);
let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1);
egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| {
for (id, label) in LAYOUTS {
if ui.selectable_label(bs.print_layout == id, label).clicked() {
bs.print_layout = id;
@@ -1087,27 +1096,38 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout"));
ui.add_space(4.0);
// PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are
// for notes pages and handouts.
ui.add_enabled_ui(bs.print_layout != "slides", |ui| {
let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1);
egui::ComboBox::from_id_salt("backstage_print_paper").width(240.0).selected_text(paper).show_ui(ui, |ui| {
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
if ui.selectable_label(bs.print_paper == id, label).clicked() {
bs.print_paper = id;
egui::ComboBox::from_id_salt("backstage_print_paper")
.width(240.0)
.selected_text(paper)
.show_ui(ui, |ui| {
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
if ui.selectable_label(bs.print_paper == id, label).clicked() {
bs.print_paper = id;
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size"));
ui.add_space(4.0);
let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" };
egui::ComboBox::from_id_salt("backstage_print_orientation").width(240.0).selected_text(orient).show_ui(ui, |ui| {
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
bs.print_landscape = land;
egui::ComboBox::from_id_salt("backstage_print_orientation")
.width(240.0)
.selected_text(orient)
.show_ui(ui, |ui| {
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
bs.print_landscape = land;
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation"));
});
ui.add_space(4.0);
if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() {
@@ -1230,10 +1250,11 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
});
}
/// Draw a labelled icon button and return whether it was clicked while enabled.
fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
if resp.hovered() && enabled {
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
}
@@ -1280,7 +1301,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
ExportTask::Handouts => {
ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text));
bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]);
egui::ComboBox::from_id_salt("backstage_handouts")
let combo = egui::ComboBox::from_id_salt("backstage_handouts")
.width(240.0)
.selected_text(format!("{} slides per page", bs.handouts_per_page))
.show_ui(ui, |ui| {
@@ -1290,6 +1311,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page"));
ui.add_space(10.0);
if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() {
app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page}));
@@ -1301,7 +1323,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") {
let selected = bs.file_type == *id;
let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, *label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label));
let p = ui.painter();
if selected {
p.rect_filled(rect, CornerRadius::same(2), t.selected);
@@ -1351,13 +1373,18 @@ fn account(app: &mut SlideApp, ui: &mut Ui) {
ui.set_width(340.0);
heading(ui, "Appearance");
let dark = app.ui.brightness == theme::Brightness::Dark;
egui::ComboBox::from_id_salt("backstage_appearance").width(260.0).selected_text(if dark { "Dark" } else { "Light" }).show_ui(ui, |ui| {
for (on, label) in [(false, "Light"), (true, "Dark")] {
if ui.selectable_label(dark == on, label).clicked() {
let _ = app.run("view.dark", json!({"on": on}));
egui::ComboBox::from_id_salt("backstage_appearance")
.width(260.0)
.selected_text(if dark { "Dark" } else { "Light" })
.show_ui(ui, |ui| {
for (on, label) in [(false, "Light"), (true, "Dark")] {
if ui.selectable_label(dark == on, label).clicked() {
let _ = app.run("view.dark", json!({"on": on}));
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance"));
ui.add_space(16.0);
heading(ui, "User Information");
dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device.");
+96 -1
View File
@@ -12,7 +12,7 @@ use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use super::*;
use crate::backstage::{Backstage, NAV_W, Nav, Page, PrintScope, entries, format_bytes, print_page_size, print_params, print_slides, split_path};
use crate::{Services, SlideApp};
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
@@ -456,6 +456,101 @@ fn the_start_screen_is_file_home() {
assert!(h.state().backstage.is_none());
}
/// The start screen is not a hidden slide: typing, pasting and IME events must not create or
/// edit a presentation while the File-tab navigation keys still work.
#[test]
fn start_screen_ignores_text_paste_and_ime_while_file_keys_navigate() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(page(&h), Some(Page::Home));
h.input_mut().events.extend([
egui::Event::Text("x".into()),
egui::Event::Paste("pasted".into()),
egui::Event::Ime(egui::ImeEvent::Preedit { text: "あ".into(), active_range_chars: None }),
egui::Event::Ime(egui::ImeEvent::Commit("あ".into())),
egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() },
]);
h.run_steps(2);
assert!(h.state().session.active().is_none(), "no typing or IME event opened a presentation");
assert!(h.state().session.documents().is_empty(), "the start screen has no slide to edit");
assert!(h.state().status.is_none(), "typing, paste and IME were ignored, not run and refused");
assert_eq!(page(&h), Some(Page::New), "the File-tab arrow still navigates");
assert!(h.output().platform_output.ime.is_none(), "the start screen publishes no input-method descriptor");
}
/// The start screen keeps the shortcuts that open something in the app: the command palette and
/// Preferences. Both are in-app windows, so no native dialog is involved.
#[test]
fn start_screen_opens_the_palette_and_preferences() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::P);
h.run_steps(3);
assert!(h.state().palette.is_some(), "Ctrl+Shift+P opens the command palette");
assert!(h.state().session.active().is_none(), "without creating a presentation");
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
h.key_press_modifiers(Modifiers::COMMAND, Key::Comma);
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"), "Ctrl+, opens Preferences");
assert!(h.state().session.active().is_none(), "without creating a presentation");
}
/// Ctrl+O still asks the app's open dialog on the start screen, and Alt+F opens the File tab with
/// the keyboard in its side menu. The open dialog is a mock service, not the host's picker.
#[test]
fn start_screen_opens_files_and_the_file_tab_from_the_keyboard() {
let opens: Rc<RefCell<Vec<String>>> = Default::default();
let log = opens.clone();
let services = Services {
pick_open: Some(Box::new(move |purpose: &str| {
log.borrow_mut().push(purpose.to_string());
None
})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::new(), services));
h.key_press_modifiers(Modifiers::COMMAND, Key::O);
h.run_steps(3);
let asked = opens.borrow();
assert_eq!(asked.len(), 1, "Ctrl+O asks the app's open dialog once");
assert_eq!(asked[0], "open", "Ctrl+O reaches the app's open dialog");
assert!(h.state().session.active().is_none(), "a cancelled open leaves the start screen");
assert_eq!(page(&h), Some(Page::Home));
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), None);
h.key_press_modifiers(Modifiers::ALT, Key::F);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home), "Alt+F opens the File tab on Home");
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0), "with the keyboard in the side menu");
}
/// Document commands stay off the empty start screen: no save picker, find palette, hyperlink
/// dialog, close prompt, status error or page change.
#[test]
fn start_screen_ignores_document_shortcuts() {
let saves: Rc<RefCell<Vec<String>>> = Default::default();
let log = saves.clone();
let services = Services {
pick_save: Some(Box::new(move |suggested: &str| {
log.borrow_mut().push(suggested.to_string());
None
})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::new(), services));
for (mods, key) in [(Modifiers::COMMAND, Key::S), (Modifiers::COMMAND, Key::K), (Modifiers::COMMAND, Key::F), (Modifiers::COMMAND, Key::W)] {
h.key_press_modifiers(mods, key);
h.run_steps(2);
assert!(h.state().session.active().is_none(), "{key:?} did not open a presentation");
assert!(h.state().session.documents().is_empty(), "{key:?} created no document");
assert_eq!(page(&h), Some(Page::Home), "{key:?} left the start screen in place");
assert!(h.state().status.is_none(), "{key:?} was ignored rather than run and refused");
assert!(h.state().dialog.is_none(), "{key:?} opened no document dialog");
assert!(h.state().palette.is_none(), "{key:?} opened no find palette");
}
assert!(saves.borrow().is_empty(), "Ctrl+S did not ask for a file");
}
/// #346: a file picked in Open that can't be read used to drop back to the start screen without
/// a word. The reason now shows over the start screen.
#[test]
+64 -37
View File
@@ -41,6 +41,12 @@ fn mods(ui: &Ui) -> Mods {
/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom
/// and two-finger scrolling are left as they are.
pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) {
if ctx.will_discard() {
// Multi-pass layout runs this function again with the input already consumed. Acting on
// the discarded pass would start the twist and the replay would immediately end it,
// splitting one gesture into two undo steps.
return;
}
let delta = ctx.input(|i| i.rotation_delta());
let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 };
let turning = delta.abs() > 1e-4;
@@ -137,6 +143,8 @@ fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 {
})
}
/// Paint the editing canvas, including rulers, selection handles, overlays, and current
/// zoom/scroll state.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let avail = ui.available_rect_before_wrap();
@@ -144,7 +152,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
// The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another
// one is drawn here, hovered or not, so switching away and back starts from zero.
let carry_id = ui.id().with("wheel_carry");
let carry_id = ui.scope_id().with("wheel_carry");
let doc_uid = st.uid;
ui.data_mut(|d| {
if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) {
@@ -160,7 +168,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
return;
}
// Scroll when zoomed beyond the window.
let scroll_id = ui.id().with("canvas_scroll");
let scroll_id = ui.scope_id().with("canvas_scroll");
let last_id = scroll_id.with("last");
let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default());
let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id));
@@ -241,7 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
} else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) {
// Point-based trackpad gestures keep their cooldown. Raw input avoids reading
// the smoothed tail of a discrete mouse notch as another page turn.
let gate = ui.id().with("wheel_gate");
let gate = ui.scope_id().with("wheel_gate");
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY));
let now = ui.input(|i| i.time);
if now - last > 0.35 {
@@ -355,7 +363,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
draw_guides(app, &painter, xf, slide_rect, &t);
}
if app.ui.ruler {
ruler(&painter, avail, slide_rect, scale, &t);
ruler(&painter, avail, slide_rect, scale, app.session.prefs.units, &t);
ruler_markers(app, ui, &painter, avail, xf, &t);
}
pointer(app, ui, &resp, xf);
@@ -454,17 +462,18 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
/// Turn the input method on for the slide while it takes typing, with its candidate window at
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
///
/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed
/// text, owner and cause once at the end of the pass, after every IME event and editor command
/// has settled.
fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
let r = to_global * r;
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
app.ime_pending = Some(to_global * r);
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
@@ -483,7 +492,8 @@ fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gi
}
let b = crate::media::gif_button_rect(r);
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" }));
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
if resp.clicked() {
toggle = Some(g.id);
@@ -569,44 +579,52 @@ fn master_tex(
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
/// The ruler's numbered step in points and the ticks per step: quarter inches, or half
/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a
/// tick per point or pixel would be a solid bar.
pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) {
match unit {
deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2),
_ => (72.0, 4),
}
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) {
let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0));
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y));
p.rect_filled(top, CornerRadius::ZERO, t.card);
p.rect_filled(left, CornerRadius::ZERO, t.card);
let inch = 72.0 * scale;
let mut k = 0;
loop {
let x = slide.min.x + k as f32 * inch / 4.0;
if x > slide.max.x {
break;
}
let h = if k % 4 == 0 {
let (step, sub) = ruler_steps(unit);
let tick = step as f32 * scale / sub as f32;
let len = |k: u32| {
if k.is_multiple_of(sub) {
8.0
} else if k % 2 == 0 {
} else if k.is_multiple_of(2) {
5.0
} else {
3.0
};
}
};
let mut k = 0;
loop {
let x = slide.min.x + k as f32 * tick;
if x > slide.max.x {
break;
}
let h = len(k);
p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint));
if k % 4 == 0 {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim);
if k.is_multiple_of(sub) {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim);
}
k += 1;
}
let mut k = 0;
loop {
let y = slide.min.y + k as f32 * inch / 4.0;
let y = slide.min.y + k as f32 * tick;
if y > slide.max.y {
break;
}
let w = if k % 4 == 0 {
8.0
} else if k % 2 == 0 {
5.0
} else {
3.0
};
let w = len(k);
p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint));
k += 1;
}
@@ -649,8 +667,9 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top);
p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside);
let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag());
let id = ui.id().with("ruler_drag");
let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler"));
let id = ui.scope_id().with("ruler_drag");
let mut drag: Option<RulerDrag> = ui.data(|d| d.get_temp::<RulerDrag>(id));
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
if resp.drag_started()
@@ -716,17 +735,17 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
}
// The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click
// on the ruler then sets that kind of stop.
let kind_id = ui.id().with("ruler_tab_kind");
let kind_id = ui.scope_id().with("ruler_tab_kind");
let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0);
let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0));
let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click());
let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click());
const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")];
if cr.clicked() {
kind = (kind + 1) % KINDS.len();
ui.data_mut(|d| d.insert_temp(kind_id, kind));
}
let (kind_align, kind_label) = KINDS[kind % KINDS.len()];
cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label));
cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label));
let cr = cr.on_hover_text(format!("{kind_label} (click to change)"));
p.rect_stroke(
corner.shrink(2.0),
@@ -1709,8 +1728,16 @@ mod tests {
pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s)
}
/// Test helper: inject a platform-command-modified line wheel event and run thirty harness
/// steps.
fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) {
let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND };
let wheel = Event::MouseWheel {
unit: MouseWheelUnit::Line,
delta: vec2(0.0, lines),
phase: TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
modifiers: Modifiers::COMMAND,
};
h.event(wheel);
// egui spreads a wheel notch over several frames.
h.run_steps(30);
File diff suppressed because it is too large Load Diff
+69 -12
View File
@@ -21,6 +21,7 @@ use deckcraft_engine::{Mods, PointerEvent};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::capture::{MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MemoryPlan};
pub type ControlResponse = Value;
@@ -132,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option<f32> {
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
}
/// Dispatch one decoded control request and return an outcome for its caller to reply with.
pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
@@ -173,8 +175,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
&& !show.ended
&& let Some(key) = key_from(k)
{
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
return ok(json!({"show": true}));
}
wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string()))
@@ -197,7 +199,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
} else {
return err("missing `preedit` or `commit`");
};
app.synthetic.push(egui::Event::Ime(ev));
app.queue_input(egui::Event::Ime(ev));
ok(json!({"queued": true}))
}
"ui.tool.select" => {
@@ -213,7 +215,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
"ui.move" | "ui.click" | "ui.drag" => {
let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") };
let pos = egui::pos2(x, y);
app.synthetic.push(egui::Event::PointerMoved(pos));
app.queue_input(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
Some("right" | "secondary") => egui::PointerButton::Secondary,
@@ -221,13 +223,13 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
}
} else if req.method == "ui.drag" {
let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") };
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120);
app.synthetic.push(egui::Event::PointerButton {
app.queue_input(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
@@ -235,9 +237,9 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
});
for k in 1..=steps {
let a = k as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
app.queue_input(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
}
app.synthetic.push(egui::Event::PointerButton {
app.queue_input(egui::Event::PointerButton {
pos: egui::pos2(tx, ty),
button: egui::PointerButton::Primary,
pressed: false,
@@ -245,7 +247,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
});
}
ctx.request_repaint();
ok(json!({"queued": app.synthetic.len()}))
ok(json!({"queued": app.synthetic_queued_len()}))
}
"ui.set" => {
if let Some(t) = s("tab") {
@@ -346,7 +348,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"show.end" => {
if app.show.is_some() {
app.synthetic.push(egui::Event::Key {
app.queue_input(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
@@ -409,11 +411,38 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<deckcraft_engine::T
})
}
/// Encode and write a screenshot, or return inline base64 data, while enforcing the capture
/// budgets and reporting encoding and write errors.
pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let inline = path.is_none();
// The request was reserved from the same geometry; re-check the exact image before making
// the RGBA/PNG/base64 copies so a mismatched or oversized completion cannot allocate past
// the capture budget. A path reply deliberately never builds the base64 text.
let Some(plan) = MemoryPlan::for_image(w, h, inline) else {
return json!({"ok": false, "error": "the screenshot has no usable image size"});
};
if plan.color_image > MAX_IMAGE_BYTES {
return json!({"ok": false, "error": "the screenshot would exceed 128 MiB; use ui.render"});
}
if plan.peak > MAX_TOTAL_BYTES {
return json!({"ok": false, "error": "the screenshot would exceed the 256 MiB capture budget; use ui.render"});
}
if image.pixels.len().checked_mul(4) != Some(plan.color_image) {
return json!({"ok": false, "error": "the screenshot pixel data does not match its size"});
}
let Some((width, height)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()) else {
return json!({"ok": false, "error": "the screenshot has no usable image size"});
};
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let img = deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba };
let img = deckcraft_render::Image { width, height, pixels: rgba };
let png = img.to_png();
if png.is_empty() {
return json!({"ok": false, "error": "the screenshot could not be encoded"});
}
if png.len() > plan.png {
return json!({"ok": false, "error": "the screenshot PNG exceeded its reserved budget"});
}
let Some(path) = path else {
return json!({"ok": true, "result": {"width": w, "height": h, "png": deckcraft_engine::cmd::base64_encode(&png)}});
};
@@ -432,6 +461,34 @@ mod tests {
use super::*;
/// A path reply writes the PNG and returns no base64 text; an inline reply returns PNG
/// bytes base64-encoded once. Both go through the same checked memory plan.
#[test]
fn screenshot_reply_is_path_only_or_explicitly_inline() {
use std::sync::{Arc, Mutex};
let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());
let written = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&written);
app.services.write = Some(Box::new(move |_path, bytes| {
*sink.lock().unwrap() = bytes.to_vec();
Ok(())
}));
let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3));
let path_reply = save_screenshot(&mut app, &image, Some("/tmp/shot.png"));
assert_eq!(path_reply["ok"], true, "{path_reply}");
assert!(path_reply["result"].get("png").is_none(), "a path reply never builds base64");
let png = written.lock().unwrap().clone();
assert_eq!(png.get(..8), Some(&b"\x89PNG\r\n\x1a\n"[..]), "the writer gets a PNG");
let inline = save_screenshot(&mut app, &image, None);
assert_eq!(inline["ok"], true, "{inline}");
let text = inline["result"]["png"].as_str().expect("inline base64");
let decoded = deckcraft_engine::cmd::base64_decode(text).expect("valid base64");
assert_eq!(decoded, png, "the inline reply is the same PNG");
}
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
#[test]
fn take_and_abandon_are_exclusive() {

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