Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor ac3e46b748 Metafile: read WMF, EMF and EMF+ with craft-metafile; delete crates/metafile
The renderer now draws what the shared reader adds: pen caps, joins and
dashes, clip paths (SELECTCLIPPATH) and turned or skewed bitmaps.
2026-11-12 01:09:45 +00:00
FredApps 2127bd1bd8 CI: nightly release dry run (#2052)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:03:54 +01:00
Dylan M. Taylor eec4f59535 AV1: decode tiles serially when parallel tile metadata copies would be huge (#1841) 2026-11-11 17:00:12 -05:00
Dylan M. Taylorandaiongg 988a492097 PPTX: copy only used, bounded namespace declarations onto imported fragments (#1740)
* pptx: copy only used, bounded namespace declarations onto imported fragments

* address review: index a branch's namespace declarations once, look up each child's prefixes

* address review: oversized declarations keep shadowing; keep MustUnderstand prefixes

* address review: keep declarations for prefixes used in QName attribute values

* Apply review fixes

- crates/pptx/src/read.rs

* address review: keep 400-part cap inclusive for rels part

* address review: budget the namespace bytes copied onto kept fragments per part

* fix test initializer: Part gained ns_budget

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 16:15:23 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 2406ce46ec chore: bump version to 0.18.1 for the Flatpak update fix (#2048)
Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 15:25:10 -05:00
Dylan M. Taylor 60f91f7806 Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs (#1778)
* Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs

* address review: size AV1 repeats by the reference format and after layer selection, budget HEVC queued frames at their own size

* address review: charge the larger of the output queue and the in-flight reservation, not both

* address review: count frames held by the old DPB at their own size in the budget check

* address review: charge queued frames outside the DPB and the in-flight set separately
2026-11-11 15:13:36 -05:00
Dylan M. Taylor 2ef5f31bc8 PPTX export: stop adding chart series once a group's caches pass a point budget (#1804)
* PPTX export: stop adding chart series once a group's caches pass a point budget

* address review: size chart exports from the caches each group writes

* address review: cut each chart data cache at a cap far above Excel's 32,000 points per series, so the first series is bounded without failing export
2026-11-11 15:13:23 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> c6846f6a75 Photo album: apply the album limits when reopening an album (#1755)
* engine: apply the photo album limits when reopening an album

* address review: refuse an album Update when the editor seed omitted items, instead of deleting them

* Apply review fixes

- crates/engine/src/cmd/insert/album.rs

* address review: refuse an album replace when a slide past the item limit holds other content

* address review: notes and comments on a shapeless slide past the item limit block an album replace

* address review: slide background, transition, animations, colour map and tags count as slide content

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:47 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> a3f2f215b0 Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets (#1777)
* Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets

* address review: count shared tiles once, include text advances, check clip region before keeping an item

* address review: count EMF+ object bytes against the canvas byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs
- crates/metafile/src/tests.rs

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count pieces of a continued EMF+ object against the shared byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count the current and saved clips against the shared byte budget

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:32 -05:00
Dylan M. Taylor 35166b3841 Media: Opus, Ogg, MP4 and Matroska come from craft-libs (#2047)
DeckCraft kept its own Opus decoder (crates/opus) and Ogg demuxer
(crates/ogg), and two crates that only re-exported craft-media
(crates/isobmff, crates/matroska). craft-libs has all four: craft-audio's
opus module and craft-media's ogg, isobmff and matroska modules. The four
crates are removed and crates/media depends on craft-audio and craft-media
directly.

craft-media and craft-audio are pinned at 113f2360, the head of craft-libs
#810, which carries the two Ogg fixes DeckCraft made to its own copy
(51d19baa, 9d36b593). The pin moves to craft-libs main once #810 merges.
craft-pdf, craft-update and craft-ui keep their pins.

One API difference: craft-media's OpusTiming::of saturates on a hostile
granule position instead of returning an error. The end-trim product in
the Ogg Opus decode now saturates as well; a test with a three-channel
file and the largest granule panicked on it without that.

crates/bitstream stays: it also holds the video decoders' memory budget,
which craft-libs does not have.
2026-11-11 14:47:18 -05:00
Dylan M. Taylor b57b4ccfcf PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets (#1790)
* PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets

* address review: count all chart vectors and relationship strings; reject a chart that crosses the presentation limit

* address review: charge chart part names/content types, roll back rejected capture

* address review: bound chart decode work across rejected and repeated references, cache refused sidecar captures

* address review: keep a chart past the presentation chart limits as its original part and closure, so a save writes it back
2026-11-11 14:33:58 -05:00
Dylan M. Taylor 2fbdcfe5f5 UI: cap live video decoders and bound slide-show grid thumbnail height (#1801)
* UI: cap live video decoders and bound slide-show grid thumbnail height

* address review: retry video feed creation when playback resumes

* address review: reclaim the decoder of ended videos when the feed cap is reached

* test: cover the video decoder cap and its retry

* address review: seeking a video without a decoder retries the bounded decoder allocation
2026-11-11 14:33:44 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 98eac5590d Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills (#1786)
* Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills

* address review: skip shape gradient fill when the outline is over the point limit

* address review: keep every contour of a compound bevel or shape gradient at a coarser raster, bound cubic subdivision before flattening

* Apply review fixes

- crates/render/src/pathgrad.rs

* Apply review fixes

- crates/render/src/pathgrad.rs

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:33:29 -05:00
Dylan M. Taylor 46712d0293 Fonts: honor the disabled system fallback for symbol and script family loads (#1840)
* Fonts: honor the disabled system fallback for symbol and script family loads

* address review: no catalog loading for loaded families when system fallback is off

* test: cascades load no installed family when system fallback is off
2026-11-11 14:33:15 -05:00
Dylan M. Taylor e03c22c445 Notes: place bullets in one pass over the galley rows (#1833)
* Notes: place bullets in one pass over the galley rows

* address review: keep zero-character leading rows in row_span_at
2026-11-11 14:33:01 -05:00
Dylan M. Taylorandaiongg 98208a9eda PDF: gather a paragraph's text once, keep note line widths, count handout rules (#1773)
* pdf: gather a paragraph's text once, keep note line widths, count handout rules

* pdf: spell the rule-count check without a negated comparison

* address review: use a narrow tall page in the bounded-work test

* address review: require the huge-paper export to succeed

* Apply review fixes

- crates/pdf/src/lib.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 14:32:47 -05:00
Dylan M. Taylor de52b6b766 MCP: bound a batch's entries and the bytes of results it gathers (#1759)
* mcp: bound a batch's entries and the bytes of results it gathers

* address review: charge the escaped batch reply against the budget

* address review: charge batch error entries against the byte budget

* address review: truncate id in batch limit entry

* address review: count the pretty-printed batch reply against the byte cap
2026-11-11 14:32:33 -05:00
Dylan M. Taylor 7c83fa43ac Spelling: check the automation write roots before appending to the user dictionary (#1754)
* engine: check the automation write roots before appending to the user dictionary

* address review: check the original Path for the dictionary write; isolate the confinement test
2026-11-11 14:32:18 -05:00
Dylan M. Taylor 7fe979aa24 Fix Flatpak permissions for update checks and self-updates (#2045)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:18:59 -05:00
neilcawse 690171665a pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections (#1944)
* pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections

* fix(pptx): apply unconnected-point SmartArt outline fallback when type="doc" point is absent

* fix(pptx): restrict SmartArt pt_order fallback to empty parOf graphs and preserve cached Picture/Connector children

* fix(pptx): convert default rect geometry to line for connectors in diagram drawings

* docs(pptx): note flat ptLst order fallback on dgm::outline
2026-11-11 18:48:47 +00:00
Dylan M. Taylor 203fa79211 Merge pull request #2041 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:51 -05:00
Dylan M. Taylor 3e2e73aab7 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:42 -05:00
Dylan M. Taylor cf0d7fbb14 ci: run Harden-Runner on every job (#2039)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:10:27 -05:00
FredApps 121c59d521 Release: DeckCraft v0.18.0 (#2033) 2026-11-11 16:35:45 +01:00
Dylan M. Taylor a55457ee53 Spelling: stop an affix header's count at the end of the file, share aliased dictionaries (#1794)
* Spelling: stop an affix header's count at the end of the file, share aliased dictionaries

* address review: share the first inserted dictionary on a concurrent load
2026-11-11 04:16:05 -05:00
Dylan M. Taylor 7bd141c1c9 xtask: match attribution globs with a table, not backtracking (#1797) 2026-11-11 04:07:19 -05:00
Dylan M. Taylorandaiongg e3e218a8fc Shape edits: edit each target once and read its box without copying it (#1769)
* engine: edit each target shape once and read its box without copying it

* engine: check the fill colour field in the repeated-ids test

* Apply review fixes

- crates/engine/src/tests.rs

* address review: shape.lock skips targets inside a targeted group

* address review: dedupe shape.lock targets before walking groups

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 04:00:58 -05:00
Dylan M. Taylor c532ae749b Logging: escape control characters and mark continuation lines in log records (#1791) 2026-11-11 03:52:36 -05:00
Dylan M. Taylor e0f3639e94 Undo: count a slide's notes, comments and tags in the memory budget (#1768)
* engine: count a slide's notes, comments and tags in the undo memory budget

* engine: drive the undo-budget test through slide.rename

* address review: size text cost by the Paragraph and Run struct sizes
2026-11-11 03:44:11 -05:00
Dylan M. Taylor eb10e2b9b3 Metafile: bound clipping work across all subpaths of one item (#1738)
* metafile: bound clipping work across all subpaths of one drawn item

* address review: charge fill clipping as polygons grow, one budget per item

* address review: charge actual clip work instead of projected polygon growth

* address review: share one clip-work allowance between fill and stroke

* address review: share one clip-work budget across fill and stroke, give region intersection its own budget
2026-11-11 03:35:40 -05:00
Dylan M. Taylorandaiongg 92a0437ab6 Media: check an MP4's sample tables where Symphonia will start reading it (#1737)
* media: check an MP4 for oversized sample tables where symphonia will start reading it

* address review: run MP4 table guard only for MP4 or unknown containers

* Apply review fixes

- crates/media/src/probe.rs
- crates/media/src/mp4guard.rs
- crates/media/src/audio.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 03:28:15 -05:00
Dylan M. Taylor 4e87f2b850 H.264: bound decoder work from overlapping slices, gaps and queued output (#1706)
* H.264: bound decoder work from overlapping slices, gaps and queued output

- A macroblock that an earlier slice of the same picture already decoded is
  refused, so overlapping slices cannot redo the picture's reconstruction
  once per slice.
- The memory budget check counts pictures waiting in the output queue that
  have left the DPB (one decode call may carry many access units) and the
  DPB's actual size, counting shared buffers once.
- frame_num gap concealment advances the POC state across the skipped run in
  constant time instead of once per missing frame_num.
- Storing a reference picture evicts until the DPB is under capacity, rather
  than evicting one picture and inserting regardless.

* address review: charge pictures already held at their own size in the decoder budget

* address review: keep the h264 output-queue byte total incrementally
2026-11-11 03:17:58 -05:00
Dylan M. Taylorandaiongg 54846750d4 Ink: extend a stroke in place and cap a gesture's samples (#1770)
* engine: extend an ink stroke in place and cap a gesture's samples

* engine: move the ink extension out of drag

* address review: close scribbles at the release point; keep vertices when a capped press adds none

* Apply review fixes

- crates/engine/src/tools/pointer.rs

* raise ink gesture cap to 100000 points

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 18:42:24 -05:00
Dylan M. Taylorandaiongg 1e9c392f67 Spelling: remember failed compound tails (#1835)
* Spelling: remember failed compound tails

* Apply review fixes

- crates/engine/src/spell.rs

* Apply review fixes

- crates/engine/src/spell.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 17:54:53 -05:00
Dylan M. Taylorandaiongg c3fb184e9d engine: index font names and embedded faces instead of rescanning (#1749)
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 17:54:22 -05:00
Dylan M. Taylor 0623b43032 Merge pull request #1985 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:55:36 -05:00
dotCooCoo 5ee82b4e45 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:26 -08:00
Dylan M. Taylor 61423ea35b Merge pull request #1980 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:17 -05:00
aiongg c4674f24fb Merge pull request #1982 from Silvertree2010/fix/h13151-polynomial-trendline-fit-fails-f
Make polynomial trendline fits independent of x scale and offset
2026-11-08 15:46:38 -05:00
NetterSilverandClaude Opus 5.5 4ba9a1a5d9 Make polynomial trendline fits independent of x scale and offset
Found while testing crates/model/src/trendline.rs:~419 (solve, used by polyfit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:33:22 +01:00
dotCooCoo 3504a9da42 xtask: pdf-deps expects craft-pdf and krilla from the moved craft-libs pin
The pdf-deps check compared craft-pdf and krilla against storytold/craft-libs
at c5a1cbfb, so the gate stopped at that step once the pin moved to
dotCooCoo/craft-libs c7c84230. The expected repository and revision now
match the pin in Cargo.toml.
2026-11-08 12:11:03 -08:00
dotCooCoo ccab980657 Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-media, craft-pdf, craft-ui, craft-update and krilla move from craft-libs
c5a1cbfb to c7c84230, which carries storytold/craft-libs#687 and #696.

The check result window now shows whenever the updater's status is not
idle: after Help > Check for Updates, and at the first start after an
update, to say that it installed or why it failed. Restart to Update in
that window asks the app to close once nothing is unsaved. on_exit starts
the helper of a ready update, since on macOS it ends the process before
the app is dropped.
2026-11-08 11:30:36 -08:00
aiongg 48e2203123 Merge pull request #1969 from Silvertree2010/fix/h11403-sentence-case-capitalizes-the-wo
Don't capitalize the word after a decimal point in sentence case
2026-11-08 03:35:42 -05:00
NetterSilverandClaude Haiku 5.5 76bd26cca0 Don't capitalize the word after a decimal point in sentence case
Found while testing crates/model/src/edit.rs:~505 (change_case_parts, sentence branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 08:26:05 +00:00
Nasim 02fbb6a976 Notes: pin that Paste reaches the focused speaker-notes field, bare and inside the ⌘V batch (refs #1855) (#1856) 2026-11-08 01:59:22 -05:00
aiongg c16db2ef3e Merge pull request #1943 from neilcawse/fix/pptx-body-and-group-3d
pptx: preserve 3-D scene and extrusion XML on text bodies and groups
2026-11-08 01:18:16 -05:00
aiongg 809c44654d Apply review fixes
- crates/pptx/src/write/shapes.rs
2026-11-08 05:59:15 +00:00
aiongg 220b52dfdb Apply review fixes
- docs/file-format-parity.md
2026-11-08 05:43:41 +00:00
Neil Cawse 424f575ca5 fix(pptx): bound and validate body/group raw3d XML on read/write and expand 3-D tests 2026-11-08 05:26:33 +00:00
Neil Cawse aefd54d28e pptx: preserve 3-D scene and extrusion XML on text bodies and groups 2026-11-08 05:24:59 +00:00
Nasim 25809faf1a Accessibility: name ribbon drop-downs by purpose and expose the shown text as their value (#1956) (#1963) 2026-11-08 00:18:14 -05:00
aiongg fc35ad67c6 Merge pull request #1950 from Silvertree2010/fix/h9583-format-paragraph-silently-left-a
Fix format.paragraph silently left-aligning known align names
2026-11-07 15:10:55 -05:00
NetterSilverandClaude Haiku 5.5 57780f9e61 Fix format.paragraph silently left-aligning known align names
Found while testing crates/engine/src/cmd/format/paragraph.rs:123-129

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:00:33 +00:00
FredApps 0627fb2589 Merge pull request #1932 from helenkwok/web/page-api
deckcraft-web: window.deckcraft, the page command API from craft-web-api
2026-11-07 15:37:54 +01:00
aiongg 170c5591b5 Merge pull request #1934 from Silvertree2010/fix/h7742-shape-adjust-with-an-invalid-neg
Reject invalid index in shape.adjust and shape.quickStyle
2026-11-07 03:34:42 -05:00
NetterSilverandClaude Haiku 5.5 a883d6b2d9 Reject invalid index in shape.adjust and shape.quickStyle
Found while testing crates/engine/src/cmd/shape/appearance.rs:8 (adjust, index via usize_param); same pattern in quick_style at :559

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 08:14:47 +00:00
Helen Kwok 4b1e50a6b0 deckcraft-web: put each Cargo.toml comment above its own dependency, and say a call the app already took still finishes after a timeout 2026-11-07 10:28:04 +08:00
Helen Kwok c7919102ac deckcraft-web: window.deckcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:05:09 +08:00
FredApps 159ed07eb3 Merge pull request #1918 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 15:00:00 +01:00
FredApps de9e79a445 docs: preserve local guidance in house-rules rollout 2026-11-06 12:36:46 +01:00
FredApps d7343553b3 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
Luis 9a95c3a639 Release: refresh contributor credits for each release (#1889) (#1890)
About > Contributors still showed the snapshot taken for v0.4.0, because
nothing regenerated contributors/contributors.json before a release.
Opt in to the shared refresh from craft-common-actions: a read-only
credits job regenerates the snapshot for the release commit, and the
shared release workflow installs it before every source build. The
snapshot is not committed back.
2026-11-05 17:23:44 +01:00
FredApps daeedd5d19 Merge pull request #1895 from helenkwok/web/host-crate
deckcraft-web: host messages through craft-web-host, and page host mode
2026-11-05 14:11:06 +01:00
FredAppsandCursor a2c1633fe3 fix(web): refuse a host file of exactly 2 GiB
wasm32 cannot allocate a Vec of 2 GiB (isize::MAX is one byte less), so
the open check that only rejected a larger file trapped on that boundary.
Refuse 2 GiB as well, and say so in the failure the host receives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:59:10 +01:00
aiongg bfdcc5cdfa Apply review fixes
- packaging/web/README.md
- apps/deckcraft-web/src/host.rs
2026-11-05 11:55:08 +00:00
Michael Müllerandaiongg 73de9900a7 Show: Presenter View only with a second display (#1156)
* Show: Presenter View only with a second display

With one display the presenter window opened on the same screen as the show. Under Hyprland's
focus-follows-mouse, whichever window the pointer was over took full screen, and the hidden one
stalled every frame: the show lagged, dropped key presses or froze.

One display now shows only the slides, as in PowerPoint. The host counts displays through winit's
monitor list when a show starts; hosts that can't tell keep Presenter View.

* Show: count displays by MonitorHandle

Wayland lists one output twice under the same wl_output id, so comparing handles counts it once, and two separate displays with the same name and geometry still count as two.

* Show: docs for Presenter View with a second display

slideshow-parity and hardware-parity note that on Linux one display shows only the slides, with status lines and revision history; ROADMAP progress log. display_count collects into a BTreeSet (MonitorHandle's Ord matches its Eq).

* Show: docs state the one-display rule exactly

Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept. The host callback covers Unix other than macOS, so the docs say Linux and the BSDs.

* Apply review fixes

- docs/slideshow-parity.md

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-05 12:51:16 +01:00
aiongg b225bb16a1 Apply review fixes
- apps/deckcraft-web/src/host.rs
2026-11-05 11:49:17 +00:00
Helen Kwok 9299113268 deckcraft-web: speak the host messages through craft-web-host, and add page host mode
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420). `data-host="page"` on the canvas now hosts DeckCraft in a page's own document, as in the other apps; the parent bridge keeps its behaviour.
2026-11-05 17:51:01 +08:00
FredApps 2b6d27d525 Merge pull request #1658 from SSchott/copy-the-fix
Fix Copying Images, Ctrl-C/Ctrl-V and within Deckcraft
2026-11-05 05:59:48 +01:00
FredApps 9998ee5e07 Merge pull request #1809 from jimprince/web-host-bridge
Web: open and save through a framing page (?host=parent)
2026-11-05 05:59:44 +01:00
FredApps 9e8aa81298 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:49:57 +01:00
FredApps 6b5faf6d51 Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:49:57 +01:00
FredApps 0b5cd0f2d6 Merge pull request #1876 from zberry/connector-glue-in-groups
shape.connect glues to a shape inside a group, and the connector follows it
2026-11-05 05:43:49 +01:00
FredApps 4f171e8f78 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:41:44 +01:00
FredApps edeb99b00e Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:41:44 +01:00
FredApps 29162e66ad Merge main into connector-glue-in-groups (keep both tests) 2026-11-05 05:32:34 +01:00
FredApps 8435cc301f Merge pull request #1886 from sbjwin/fix/thumbnail-grayscale
UI: follow Grayscale view mode in slide thumbnails
2026-11-05 05:31:42 +01:00
FredApps 75885b49de Merge pull request #1881 from zberry/smartart-no-flip
Flip is refused on a SmartArt graphic instead of turning it into a plain group
2026-11-05 05:31:38 +01:00
FredApps f7b728d0c2 Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:25:08 +01:00
FredApps d82aaddab4 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:24:59 +01:00
FredApps 8f26681a1f Merge main into connector-glue-in-groups (keep both tests) 2026-11-05 05:21:16 +01:00
FredApps f2356fcf63 Merge main into flip-smartart (keep both tests) 2026-11-05 05:19:55 +01:00
FredApps 60132037b2 Merge branch 'main' into web-host-bridge
Keep both ROADMAP progress entries: Ogg Opus duration from main and the
web ?host=parent bridge from this branch.
2026-11-05 05:19:47 +01:00
FredApps ac09977c12 Merge pull request #1875 from zberry/table-cells-refused
Table cell commands refuse a malformed `cells` list instead of dropping entries
2026-11-05 05:19:11 +01:00
FredApps 480cbfd4ef Textures::thumb: document the stale thumbnail returned over budget 2026-11-05 05:18:38 +01:00
FredApps afc7f765c9 shape.connect: refuse a grouped non-line id as not a line 2026-11-05 05:18:29 +01:00
aiongg d9186c7ce1 Apply review fixes
- crates/engine/src/cmd/shape/size.rs
2026-11-05 04:04:02 +00:00
aiongg 75744dd67d Apply review fixes
- crates/engine/src/tools/drag.rs
2026-11-05 04:02:42 +00:00
sbjwin ebd306acc1 UI: request repaint on stale thumbnail return to finish grayscale refresh 2026-11-05 12:51:29 +09:00
aiongg 161af3b7d5 Merge main into smartart-no-flip 2026-11-05 03:49:15 +00:00
aiongg 4e4d98e69d Merge main into table-cells-refused 2026-11-05 03:49:09 +00:00
aiongg a52b2d348f Merge main into connector-glue-in-groups 2026-11-05 03:45:22 +00:00
aiongg acd19ff4ac Merge pull request #1879 from zberry/outline-title-and-body
Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view
2026-11-04 22:38:05 -05:00
sbjwin b06d60b8ed UI: follow Grayscale view mode in slide thumbnails
In PowerPoint, turning on Grayscale view mode turns both the active
slide canvas and the slide thumbnail pane to grayscale, while Print
retains the original full color for output.

Previously, DeckCraft only passed `app.ui.grayscale` to the main canvas
texture renderer (`Textures::slide`), while thumbnail rendering
(`Textures::thumb`) hardcoded `false` and lacked `grayscale` in its
cache key. As a result, when Grayscale mode was enabled, the canvas
rendered in grayscale but the thumbnail pane remained in full color.

- Update `Textures::thumb` to accept `grayscale: bool` and include it in
  the cache key (`extra: grayscale as u64`).
- Pass `app.ui.grayscale` to `Textures::thumb` in the thumbnail pane
  (`panels/thumbs.rs`), Slide Sorter & Notes Page (`sorter.rs`), and
  Notes Master view (`canvas.rs`).
- Keep Print preview (`backstage/print.rs`) in full color (`false`) to
  match the actual exported PDF print output.
- Add regression test `to_color_image_turns_colors_to_grayscale_when_flag_is_set`.
2026-11-05 12:35:59 +09:00
FredAppsandClaude Opus 5.5 a2d9e71b5e About: credits tables fit without side-scrolling and their columns resize (#1806)
* About: credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

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

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:51 +01:00
Zach BerryandClaude Opus 5.5 fe35df948c Flip is refused on a SmartArt graphic instead of turning it into a plain group
PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design ▸ Right to
Left is the way to mirror it), also inside a group, as ungrouping would hand
the flip to the graphic. Before, a flip turned it into a plain group that the
smartArt.* commands then refused. arrange.flipHorizontal and flipVertical
reach the check through shape.flip. Split out of the SmartArt PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:09:40 -06:00
Zach BerryandClaude Opus 5.5 ede154d6be Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view
An agent run exporting a deck as an outline got the footer text and the
slide-number field (‹#›) as body lines under each title. PowerPoint's
outline view shows title and body placeholders only, so the text outline,
the RTF outline (Save As ▸ Outline/RTF) and the outline view now skip the
footer-row placeholders as they already skipped the title. Split out of the
frame export PR at review, which asked for all three together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:07:59 -06:00
FredApps 6431460e80 Merge pull request #1872 from FredApps/fix/1866-web-presenter-all-slides
Show: All slides opens in the web Presenter View, as on desktop
2026-11-05 03:04:06 +01:00
Zach BerryandClaude Opus 5.5 af9f522c6a shape.connect glues to a shape inside a group, and the connector follows the group
An agent run found that connecting to a grouped shape answered ok but left
the connector's end unset: the sites of a group member were computed in the
group's child space, and rerouting only looked at the slide's top-level
shapes, so the glue was dropped at once. Both now locate the shape anywhere
in the tree with the affine onto the slide, as PowerPoint glues to grouped
shapes. shape.sites answers in the same slide space. A line inside a group is
refused as `id`: its ends would land in the group's space, and rerouting
does not follow grouped connectors.

Split out of the connector default sites PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:48:16 -06:00
FredApps efd26de81d Show: clear the web presenter grid in hidden logic, let Enter and Space pick a focused thumbnail (#1866) 2026-11-05 02:48:01 +01:00
Zach BerryandClaude Opus 5.5 7c47d0a9ac Table cell commands refuse a malformed cells list instead of dropping entries
`cells` must be a list of exact [row, col] pairs inside the table; an entry
that is not one, a cell past the table, or more than 100000 entries is refused
and names the command, instead of being dropped or read as absent (which acted
on the selection or the whole table). Borders use checked arithmetic for the
neighbour cell. Split out of the 0-based index PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:40:18 -06:00
FredApps 884e3d3353 Show: keep web All slides messages small, clear the grid when the popup closes, show it on the end screen (#1866) 2026-11-05 02:37:37 +01:00
FredApps f49ff09b8a Show: All slides opens in the web Presenter View, as on desktop (#1866) 2026-11-05 02:25:54 +01:00
aiongg 52f87aca13 Merge pull request #1474 from zberry/pptx-show-media-controls
PPTX: Show media controls survives a save and reopen
2026-11-04 19:17:57 -05:00
aiongg dfee4c1e88 Merge pull request #1870 from Silvertree2010/fix/1864-next-and-previous-not-working-in
Make Previous/Next work in the web presenter popup
2026-11-04 19:03:38 -05:00
NetterSilverandClaude Opus 5.5 f321416b34 Make Previous/Next work in the web presenter popup
Fixes #1864

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:52:05 +01:00
aiongg 41f07a3c4e Merge main into pptx-show-media-controls 2026-11-04 23:51:52 +00:00
.CooCoo 85de6ca5d7 Shape Fill: the gradient help says which kinds rotateWithShape keeps upright (#1843)
With rotateWithShape false, the renderer lays linear and radial gradients
out upright on the slide, while rectangular and path gradients still turn
with the shape. The shape.fill params doc now says so.
2026-11-04 18:11:29 -05:00
Harrison Booth fc18a78e65 comment: reject malformed navigation indices (#1547) 2026-11-04 18:09:50 -05:00
Dylan M. Taylor 31abd2d819 Pass the Linux signing key to the release workflow (#1868) 2026-11-04 17:45:40 -05:00
Stephan Schott-VerdugoandClaude Opus 5.5 070f12cc9c Keep Source Formatting resolves colours as the renderer does
Review follow-up on #1658 (CodeRabbit), both from storing the slide's
mapped scheme in 30ccc38e:

- Copying in Slide Master view used the current slide's colour map. Use
  `ctx_for`, so the theme is that of what is being edited.
- `ColorScheme` shares an entry between `dk1` and `tx1`, so a mapped scheme
  changed direct `dk1` colours too: a dark fill pasted white. `SourceTheme`
  keeps the theme's colours and the map apart, and `bake_theme` maps only
  `tx1`, `bg1`, `tx2` and `bg2`, as `Ctx::color` does.

The browser image test now checks that non-picture pastes keep their text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:35 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 46a15a4684 Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:19:46 +01:00
aiongg 144fd28f69 Merge pull request #1192 from MarekSpodBiedry/issue-1153
Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153)
2026-11-04 16:15:06 -05:00
Stephan Schott-VerdugoandClaude Opus 5.5 30ccc38e90 Keep Source Formatting uses the slide's colour map
Review follow-up on #1658 (CodeRabbit): `source_theme` stored the master's
scheme, so a slide or layout that overrides the colour map had its colours
fixed through the wrong map. Detaching made this reach a placeholder's
inherited colours: a title drawn white under an override pasted black.
Store the scheme the slide draws with. Paste as Picture reads the same
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:22 +01:00
aiongg 65ddd8ad51 Apply review fixes
- crates/render/src/placed.rs
2026-11-04 20:55:43 +00:00
Stephan Schott-VerdugoandClaude Opus 5.5 d6cc51e07a Alt+drag detaches placeholders; Slide Master view pastes them as placeholders
Review follow-up on #1658:

- The drag duplicate cleared `ph` without detaching, so a title or body
  dragged off with Alt lost its size and bullets. Copy, Duplicate and the
  drag now share `detach_on_slide`.
- Detaching at copy time turned a slide placeholder pasted in Slide Master
  view into a regular shape. The clipboard keeps the originals, and a paste
  there takes them, so it stays a placeholder as before this PR. Keep
  Source Formatting bakes them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:43:33 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 077cd36e9f Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:38:04 +01:00
Mex 9d36b5931c Ogg: include recovered tail packets in Opus duration (#1862)
* Ogg: include recovered tail packets in Opus duration

Keep the existing missing-granule recovery cursor in step with the packet ends it assigns. Add a regression for a partially and entirely unanchored stream; existing valid end-trimming coverage remains unchanged.

Validation: rustc --edition=2024 --test crates/ogg/src/lib.rs: all 12 tests pass. Scoped rustfmt check passes. Full cargo --offline xtask ci is blocked before build by the uncached pinned egui checkout.

* Ogg: record recovery duration correction
2026-11-04 15:15:59 -05:00
aiongg c252a681a0 Merge pull request #1454 from Silvertree2010/fix/h3020-rtf-outline-import-drops-the-res
Keep whole character after a backslash in RTF outline import
2026-11-04 14:43:02 -05:00
Dylan M. Taylor 80f784d210 Fonts: ignore variation tags the font has no axis for (#1795) 2026-11-04 14:36:13 -05:00
Dylan M. Taylor 07dc2210a1 Repair: bound the notes page size like a slide (#1832) 2026-11-04 14:09:46 -05:00
Dylan M. Taylor 51d19baa0c Ogg, xtask: check packet ranges against the source first; stats skips directory links (#1796) 2026-11-04 13:56:48 -05:00
MarekSpodBiedry 50187449c2 Render tests: import place_slide_with so the date test compiles (#1153) 2026-11-04 07:52:46 +01:00
MarekSpodBiedry b8de82e2cc Merge remote-tracking branch 'origin/main' into review-1192 2026-11-04 07:51:04 +01:00
bradandClaude Opus 5.5 6eaae2bd8c Web: only the page's first file replaces the start deck; close Save As after a same-name save
A file the page sent without a name opens untitled and unchanged, like the
empty start presentation, so the page's next file closed it. Host mode now
records the start presentation (start_for_host) and only the first file
that opens takes its place, as packaging/web/README.md says.

Save As > Save under the presentation's own name changed neither the path
nor the dirty flag, so the Save As page stayed open and a second click sent
the file again. The page now also compares the revision, which every save
bumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:26:50 -06:00
Dylan M. Taylor 23ac4f7efd Fonts: bound font-controlled work in enumeration, outlines and carets (#1701)
* Fonts: bound font-controlled work in enumeration, outlines and carets

An embedded font could make each lookup do far more than real fonts need:
- enumerating a collection read up to 256 faces times 1024 named instances,
  each then built with its own shaper; now 4096 per file across all faces,
  with a set for the duplicate-style check instead of a scan;
- composite and VARC glyphs could expand one dense outline without limit,
  and the outline cache only counted entries; outlines now stop at 32768
  path elements (drawing nothing past it) and the cache is also bounded by
  the elements it holds;
- the 64-caret limit applied after filtering, so a ligature lookup could
  read every caret record; it now applies to the records read.

* address review: bound thread-local glyph cache by path elements
2026-11-03 20:39:57 -05:00
Dylan M. Taylor 69c91bb265 Render: index table style, skip list, chart point fills and waterfall bars; count each dash (#1774)
* render: look up a table's style once, keep the skip list as a set, count each dash

* render: index chart point fills, scatter x presence and waterfall bars once per chart
2026-11-03 20:39:36 -05:00
Dylan M. Taylor a790fdbc20 Render: find a glyph's line by bisection for gradient and WordArt text (#1783) 2026-11-03 20:39:14 -05:00
Dylan M. Taylor 9d59fa96d3 Text, morph: share a run's gradient between glyph runs, match morph tokens by text (#1784) 2026-11-03 20:38:53 -05:00
Dylan M. Taylor 2dfb07f2c4 Thumbnails: index section starts once (#1800) 2026-11-03 20:38:31 -05:00
Dylan M. Taylor f5fbadde63 Render: evict the least recently used animation instead of clearing the GIF cache (#1802) 2026-11-03 20:38:10 -05:00
Dylan M. Taylor 996bb42ff5 pptx: index relationships by id on read and by target on write (#1739) 2026-11-03 20:12:47 -05:00
Dylan M. Taylor 3c3d504eb1 PPTX: read a notes or comments part for the first slide that names it (#1741)
* pptx: read a notes or comments part for the first slide that names it

* address review: keep modern-comments precedence for already-owned parts
2026-11-03 20:12:24 -05:00
Dylan M. Taylor f5511d93b6 Compare: index slide, shape and media lookups and compare each picture pair once (#1752)
* engine: index review.compare lookups and compare each picture pair once

* engine: keep the compare test's inserted slides empty
2026-11-03 20:12:03 -05:00
Dylan M. Taylor c6b7de43bc engine: use sets for slide-range expansion, added slide selection and repeated-word offsets (#1756) 2026-11-03 20:05:53 -05:00
Dylan M. Taylor 65d5c28512 Text: sort a paragraph's tab stops once and find the next by binary search (#1757)
* text: sort a paragraph's tab stops once and find the next by binary search

* address review: skip the tab-stop lookup for a NaN position
2026-11-03 20:05:32 -05:00
Dylan M. Taylor d2a8be197d geom: measure each follow band once when mapping warped lines (#1758) 2026-11-03 20:05:11 -05:00
Dylan M. Taylor 872cdbf331 pptx: take nested math text once and check an object's part for relationships once (#1762) 2026-11-03 20:04:49 -05:00
Dylan M. Taylor 0233d29064 Spelling: bound a word's language and index results by offset (#1767)
* engine: bound a word's language and index spelling results by offset

* engine: pass the test run's text by value
2026-11-03 19:59:51 -05:00
Dylan M. Taylor a72a5167d0 Morph: pick the next curve to split from a heap, not a full scan (#1782) 2026-11-03 19:59:29 -05:00
bradandClaude Opus 5.5 d5471bccf0 Web: open and save through a framing page (?host=parent)
A same-origin page that frames DeckCraft's web build with ?host=parent
sends a file with deckcraft:open and gets every save back as
deckcraft:save {name, saveAs, bytes} instead of a download, with
deckcraft:ready, deckcraft:dirty and deckcraft:failed, as GridCraft,
WordCraft and PdfCraft do. Without ?host=parent nothing changes.

Save follows the desktop app. Every file from the page opens through
file.adopt like File > Open on the desktop; a presentation sent with a
presentation name keeps that name as its file, so Save (Ctrl+S, the
File tab, the Close prompt or the command palette) writes it back under
that name and format with saveAs: false. Anything else (no name, an
outline, a damaged file opened as a repaired copy, a dropped file) is
untitled, and its first Save is a new <title>.deckcraft with saveAs:
true, as is Save As. Exports and Print are copies (saveAs: false).

Host mode starts with an empty presentation that gives way to the first
file while untouched, &author= signs comments, and the URL flags are
read as whole parameters so a free-text author can't set ?show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:58:29 -06:00
Dylan M. Taylor b7d57bacab Text edit: splice inserted paragraphs once and paste runs without rescanning (#1787) 2026-11-03 19:54:06 -05:00
Dylan M. Taylor 42108d0ee7 PPTX export: index animation targets, cache build roles, serialize run properties once per text run (#1788)
* PPTX export: index animation targets, cache a paragraph's build roles, serialize run properties once per text run

* address review: warn once when a by-paragraph build is truncated
2026-11-03 19:53:44 -05:00
Dylan M. Taylor 0107186cee Engine: check the temporary file's path against the write root (#1792) 2026-11-03 19:46:09 -05:00
Dylan M. Taylor 54e24ab868 Review: replace each whole word in one pass (#1793) 2026-11-03 19:36:05 -05:00
Dylan M. Taylor 19dceffd0b pptx: index media, layouts, sections and table styles on export (#1761) 2026-11-03 14:38:07 -05:00
Dylan M. Taylor 1eec8b266c model: group box charts and stack sums in one pass, share hierarchy labels, cap number formats (#1760) 2026-11-03 14:14:24 -05:00
Dylan M. Taylor 32f069eb84 AV1, HEVC: count repeated-frame outputs incrementally and drop inherited entry-point storage (#1763)
* av1: count repeated-frame outputs as they are added

* hevc: give each slice segment its own entry-point list instead of keeping the copied one's allocation
2026-11-03 13:44:00 -05:00
Brandon ThomasandClaude Opus 5.5 aa13eeb45e Integration: community PR wave 10 (one bodyPr attribute chain, reuse imports, doc headers folded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:54:48 -05:00
Brandon Thomas 50e02490b3 Merge pull request #1720
# Conflicts:
#	crates/engine/src/cmd/reuse.rs
2026-11-03 08:53:01 -05:00
Brandon Thomas 907ebd2de0 Merge pull request #1710
# Conflicts:
#	crates/model/src/resolve.rs
#	crates/model/src/table.rs
#	crates/model/src/text.rs
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/src/write/shapes.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas ed77cc9eaa Merge pull request #1711
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas 928aa9cc16 Merge pull request #1713
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas 5cb154ce9d Merge pull request #1712
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas ef55104799 Merge pull request #1719 2026-11-03 08:52:49 -05:00
Brandon Thomas 3f29dccc0a Merge pull request #1717 2026-11-03 08:52:49 -05:00
Brandon Thomas 160c923caf Merge pull request #1716 2026-11-03 08:52:49 -05:00
Brandon Thomas 9f01f2ebdb Merge pull request #1707 2026-11-03 08:52:49 -05:00
Brandon Thomas d365e0b9de Merge pull request #1751 2026-11-03 08:52:49 -05:00
Brandon Thomas 1d181017f6 Merge pull request #1723 2026-11-03 08:52:49 -05:00
Brandon Thomas 996ca4dd65 Merge pull request #1722 2026-11-03 08:52:49 -05:00
Brandon Thomas 6c1170af62 Merge pull request #1721 2026-11-03 08:52:49 -05:00
Brandon Thomas ed62aafbe6 Merge pull request #1709 2026-11-03 08:52:49 -05:00
Brandon Thomas 9f68e8e2cc Reuse: a non-finite size ratio leaves content unscaled; cell text scaling tested; doc history kept 2026-11-03 08:52:48 -05:00
Brandon Thomas a6e2b64875 Reuse: a remapped table style's own XML carries its new id; checklist row name kept 2026-11-03 08:51:44 -05:00
Brandon Thomas c7b91ff2ec PPTX: reflection angles, skews and start position are written inside the schema's ranges 2026-11-03 08:50:28 -05:00
Brandon Thomas 6546136a42 PPTX timing: a sorted click group keeps its click on the effect that now plays first 2026-11-03 08:49:18 -05:00
Brandon Thomas 67c1d78948 PPTX: horzOverflow never reads or writes ellipsis (vertical only in ST_TextHorzOverflowType) 2026-11-03 08:47:09 -05:00
Brandon Thomas 4df4ea6c9f Text: Thai distributed paragraphs still stretch like justified ones 2026-11-03 08:41:57 -05:00
Naseq 751ac26d28 fix(pptx): place effects by their parent time node delays (fixes #1744) 2026-11-03 17:57:57 +04:30
Dylan M. Taylor 82ec8137dc Format: bound native load work for the manifest, failed media reads and layout checks (#1704)
- presentation.json gets its own 256 MiB inflation limit instead of the 2 GiB
  per-entry allowance, and is parsed in place: only a file that needs its
  null-number repair is copied.
- A media member that fails decoding or its CRC is charged for the bytes it
  inflated, so later members cannot reuse the shared media budget.
- repair() and Presentation::validate() check slide layouts against a set
  built once instead of scanning every layout per slide.
2026-11-03 07:39:27 -05:00
Stephan Schott-Verdugo ba3d74dfb5 Merge branch 'main' into copy-the-fix 2026-11-03 09:10:31 +01:00
Harrison Booth b61c5f11bd D716: preserve reflection transforms on save 2026-11-02 22:45:51 -06:00
Harrison Booth a0956fee13 Preserve alternate run language 2026-11-02 22:42:25 -06:00
Harrison Booth bf60dbb773 Fit reused slide content to destination size 2026-11-02 22:41:55 -06:00
Harrison Booth 328b4ed274 Preserve RTL table direction 2026-11-02 22:41:44 -06:00
Harrison Booth 1e5b78de37 D702: prune deleted slides from custom shows 2026-11-02 22:32:41 -06:00
Harrison Booth 73bd231589 D709: remove dangling animations from merged shapes 2026-11-02 22:32:19 -06:00
Harrison Booth 63531e2a6a Merge remote-tracking branch 'origin/main' into dc-build-d705
# Conflicts:
#	ROADMAP.md
#	docs/parity-checklist.md
2026-11-02 22:21:16 -06:00
Harrison Booth 6cdbee50a3 Reuse custom table styles with slides 2026-11-02 22:20:52 -06:00
Brandon Thomas 0c00f22403 Merge pull request #1694
Comments: the pane keeps its width and resizes again (fixes #1431)
2026-11-02 23:07:49 -05:00
Brandon Thomas 3c8dc6bc98 Merge pull request #1693
Render: tiled picture fills keep their size in scaled groups and upright tiles
2026-11-02 23:07:49 -05:00
Brandon Thomas 6ab485a096 Merge pull request #1679
Transitions: play transition preview from previous slide when Preview is clicked (#1654)
2026-11-02 23:07:48 -05:00
Brandon Thomas 597b9488f5 Merge pull request #1579
ui: label animation effect options
2026-11-02 23:07:48 -05:00
Brandon Thomas 4eb9098a7c Merge pull request #1588
Preserve bullet sizes authored in points
2026-11-02 23:07:47 -05:00
Brandon Thomas df4bb8b84a Merge pull request #1698
Reject png/jpeg targets in file.save instead of writing zip bytes

# Conflicts:
#	crates/engine/src/tests.rs
2026-11-02 23:07:47 -05:00
Brandon Thomas f66bed88ac Merge pull request #1686
Use a short temp file name in write_file for long file names
2026-11-02 23:07:46 -05:00
Brandon Thomas ee64e93c4a Merge pull request #1682
Reject invalid master and layout indices in view.slideMaster
2026-11-02 23:07:45 -05:00
Brandon Thomas f8128f9107 Merge pull request #1680
Reject unsupported handout perPage values in file.export
2026-11-02 23:07:45 -05:00
Brandon ThomasandClaude Opus 5.5 97050a353b Bullets: an own bullet size still clears an inherited follow-text flag (as #1589 did)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:34 -05:00
Brandon ThomasandClaude Opus 5.5 fb3b857f53 file.save: an explicit jpg format is refused too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:29 -05:00
Brandon ThomasandClaude Opus 5.5 ba3b57c756 file.export: a perPage that isn't a count is refused, not read as 6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:24 -05:00
Harrison Booth e4a0c2971b Keep Thai distributed text rendering outside this change 2026-11-02 22:07:16 -06:00
Harrison Booth 0f5364ee9f picture: use displayed group size for compression 2026-11-02 22:05:35 -06:00
Harrison Booth bb417bf98a Preserve Thai distributed paragraph alignment 2026-11-02 21:53:40 -06:00
Harrison Booth d00ef08fae Preserve normalized character height in PPTX 2026-11-02 21:50:20 -06:00
Harrison Booth 0d06481c34 Preserve compatible line spacing in PPTX 2026-11-02 21:47:50 -06:00
Harrison Booth 44b133a3c9 Preserve text overflow settings in PPTX 2026-11-02 21:45:18 -06:00
Harrison Booth b66ba2e10a Preserve centered anchoring on table cells 2026-11-02 21:42:44 -06:00
NetterSilverandClaude Haiku 5.5 58c24883c7 Reject png/jpeg targets in file.save instead of writing zip bytes
Found while testing crates/engine/src/cmd/file.rs:489 (save, with save_bytes at :341-349 and format_for_path at :440)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 01:14:15 +01:00
Zach BerryandClaude Opus 5.5 b9eaaeb23b Test: hidden media controls are written as PowerPoint's p14 extension
The round trip now also checks the written presProps.xml: inside p:showPr, the extension uri and
p14:showMediaCtrls with its namespace declared on the element; the default writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:03:02 -06:00
Harrison Booth 3cfd4c5e6e Parse percentage-form bullet sizes 2026-11-02 15:47:58 -06:00
Harrison Booth a200b92258 Merge remote-tracking branch 'origin/main' into dc-build-d712
# Conflicts:
#	crates/model/src/text.rs
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-11-02 15:45:48 -06:00
Harrison Booth f2803102c1 Correct animation option labels 2026-11-02 15:41:17 -06:00
Harrison Booth fd4d2a8f79 Merge remote-tracking branch 'origin/main' into dc-build-d604
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-11-02 15:36:41 -06:00
LeonardoandClaude Sonnet 5.5 e90e7f6c03 fix(render): size the tile of a picture's shadow and reflection in points too
Review of #1693 found that cast_effects built the picture content that a shadow,
a glow and a reflection are drawn from without converting a tiled fill to
points, so inside a scaled group the reflection of a tiled picture showed
child-space tiles while its face showed the right ones.

The content now goes through paint::tiled_picture_per_point with per_point_xy,
as the face does.

Test: a tiled picture's reflection in groups scaled 0.25, 1600 and 2x0.5 shows
the same colours as in an unscaled group; it fails without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:46:33 -03:00
LeonardoandClaude Sonnet 5.5 9e59b5f223 fix(render): size a group's own tile and a picture shape's own tile in points too
Review of #1693 found two more places that drew a tiled picture fill in the
shape's own (child-space) units inside a scaled group.

A group nested in a scaled group stored its own tiled fill unconverted, so the
members that take it (grpFill) got oversized tiles. The owning group now
converts the fill with per_point_xy when it stores it, so inheriting members,
directly or through a chain of grpFill groups, get the converted tile.

A picture shape (p:pic) whose blip fill is a tile passed its own fill straight
to paint::picture. It now goes through paint::tiled_picture_per_point, which
tiled_per_point also uses.

Tests: a group's tile passed to its members and a picture shape's own tile, each
in outer groups scaled 1, 0.25, 1600 and 2x0.5; both fail without the change.

Docs: the Picture and texture fill checklist row is re-assessed (remains P) with
its status line and revision history, and the ROADMAP progress log row mentions
both cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:31:10 -03:00
Luis 5cb6ba8403 Comments: the pane keeps its width and resizes again (fixes #1431)
A comment card's reply row reserved a fixed 70 px for its Resolve and
delete buttons, which need more. The row overflowed the card, the pane
grew by the overflow every frame until it stuck at its widest, and
dragging its edge no longer changed it. The buttons are now laid out
first, from the right, and the reply field takes the width they leave.
2026-11-02 21:03:34 +01:00
LeonardoandClaude Sonnet 5.5 62ada3ab9a fix(render): tiled picture fills keep their size in scaled groups and upright tiles
A tiled picture fill (a:blipFill with a:tile) was sized in the shape's own
units. Inside a group whose child space differs from its box (old decks write
child spaces ~1800 times smaller) one tile covered hundreds of boxes, so the
fill showed a single flat texel instead of the texture.

PowerPoint's own PNG export of a synthetic probe deck shows the tile keeps its
size, offset, anchor and mirroring in slide points whatever the group's scale,
uniform or stretched unevenly, nested or flipped. The tile is now converted
from points to shape units per axis (Renderer::per_point_xy, paint::
tiled_per_point), and snapped to whole device pixels per axis.

Tiles with rotWithShape="0" are drawn in device pixels and were sized in
pixels, ignoring the zoom (even outside groups); they are now sized to pixels
first.

Tests: a tile in groups of seven scales (uniform and uneven, wide and tall
tile), and an upright tile in a turned shape at zoom 1 and 2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 16:31:29 -03:00
NetterSilverandClaude Haiku 5.5 77cd617596 Use a short temp file name in write_file for long file names
Found while testing crates/engine/src/io.rs:49

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:37:19 +01:00
NetterSilverandClaude Haiku 5.5 72ac32715f Reject invalid master and layout indices in view.slideMaster
Found while testing crates/engine/src/cmd/design/master_view.rs:8 (master_view; `master` read via usize_param, `layout` at :14)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 12:21:37 +01:00
Naseq e0992c1325 Transitions: render cached previous slide with completed animation state
- Use render_frame with timeline.steps() and t = 0.0 so the previous slide
  is rendered in its final completed state during transition preview.
- Pass duration: 1000 integer ms in test to explicitly test the requested duration.
2026-11-02 15:25:31 +04:30
NetterSilverandClaude Haiku 5.5 4bed9b6c37 Reject unsupported handout perPage values in file.export
Found while testing crates/engine/src/cmd/file.rs:362 (pdf_options) with crates/pdf/src/lib.rs:364-371 (handout_boxes)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:50:54 +01:00
Naseq ea9dad8ef9 Transitions: play transition preview from previous slide when Preview is clicked (#1654)
- When Preview is clicked on the Transitions ribbon, initialize Show with
  start_transition_preview from the prior slide into the selected slide
  (or from black for slide 0).
- If tr.old is unset for a non-part transition with tr.from, render the
  previous slide texture on the first frame so the transition blends cleanly.
- Indicate active playback on the Preview button while previewing.
- Regression tests for transitions from previous slide and from black on slide 0.
2026-11-02 15:04:05 +04:30
Stephan Schott-VerdugoandClaude Opus 5.5 1192e5ef30 Detached placeholders follow each property's style precedence
Review follow-up (CodeRabbit on #1658):

- Text levels are resolved before the copy's style changes. A master
  placeholder's font reference reached the copy's text (body font in an
  accent colour for a title) although the original ignores it: text only
  takes the shape's or the layout placeholder's font reference.
- Fill, line and effects come from where `resolve::fill`, `resolve::line`
  and `resolve::effects` take them: the first shape in the chain with an
  explicit value or a style. A layout's (or the slide's own) style no
  longer loses to an explicit fill, line or effect on the master. The
  copy gets one style whose references each come from the shape that
  supplied that property; unused references are neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:09 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 f006b5d8ce Ctrl+V pastes a picture copied in a browser, not its link
A browser's Copy Image puts the picture on the clipboard together with
text that only names it (the address and alt text, or Firefox's UTF-16
text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only
looked for a picture when the text was empty, so it pasted a text box
with the link. When the text or HTML only names a picture, paste the
picture; with no picture on the clipboard the link still pastes as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:05 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 e1cfe8516e Paste and duplicate keep a placeholder's inherited look
A placeholder copied or duplicated on a slide became a regular shape by
clearing `ph`, which also dropped everything it inherited from its layout
and master: the title's 44 pt heading font, the body's bullets and indent
levels, autofit and the box. Without `ph` the text fell back to the
presentation's default style (18 pt, no bullets), on Ctrl+V and the
context menu alike.

`resolve::detach_placeholder` makes the inherited box, fill, line,
effects, body properties and every level's text style the shape's own
before `ph` is cleared. Theme references stay references, so the default
paste still follows the destination theme. Copy (so Cut and pasting into
another deck work after the source is gone) and Duplicate use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:29:43 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 cbb0c48445 Fix OS clipboard: enable eframe's clipboard feature dropped in the egui 0.37 migration
Since #1350 (v0.11.1) the desktop build listed eframe features without
`clipboard`, so egui-winit simulated a clipboard inside the app: Ctrl+V
never read text or pictures from other apps, and Ctrl+C of text (sent out
as HTML through arboard) never reached egui's clipboard, so Ctrl+V pasted
nothing or stale text. The context menu kept working because it only uses
the session clipboard.

Turn the feature back on. On native Wayland, egui-winit owns the clipboard
through winit's data device (text and PNG); leave the X11-only arboard
pasteboard (HTML, pictures) off there so copies are claimed on the Wayland
selection. Regression tests guard the feature list and the Wayland switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:19:06 +01:00
MarekSpodBiedry 74d7b844fe Merge origin/main into issue-1153 2026-11-01 07:41:05 +01:00
Harrison Booth 98138f563b Trim whitespace in bullet point sizes 2026-10-30 23:24:07 -05:00
Harrison Booth 917d4b96a9 Honor bullet size schema bounds and inheritance 2026-10-30 23:07:54 -05:00
Harrison Booth b1900e526d Preserve point-sized bullets in PPTX 2026-10-30 22:42:28 -05:00
Harrison Booth d453bee54f D604: readable animation effect labels 2026-10-30 18:57:51 -05:00
Zach BerryandClaude Sonnet 5.5 6303166a18 [PP1] show.setup showMediaControls survives a pptx save and reopen
PowerPoint stores Show media controls as the p14:showMediaCtrls extension inside
p:showPr (uri {2FDB2607-...}); absent means shown. Write it when hidden, read it back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 15:12:26 -05:00
NetterSilverandClaude Haiku 5.5 d8936f94af Keep whole character after a backslash in RTF outline import
Found while testing crates/format/src/rtf.rs:301-326 (symbol / text, reached via escape)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:58:38 +01:00
MarekSpodBiedry 813378cc20 Keep the PDF text layer aligned with line breaks, use one clock reading for raster and text layer, and test the page text (#1153) 2026-10-23 16:08:53 +02:00
MarekSpodBiedry cc6773c95f Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153) 2026-10-22 21:02:59 +02:00
233 changed files with 9061 additions and 15773 deletions
+4
View File
@@ -26,6 +26,10 @@ jobs:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+14
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "16 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -19,10 +23,18 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: deckcraft
app-title: DeckCraft
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
@@ -30,6 +42,7 @@ jobs:
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -45,3 +58,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+61 -10
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# AGENTS.md — rules for every agent and contributor
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
@@ -7,8 +60,7 @@ PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCra
(`../<app>`).
These rules bind every AI agent and every human contributor. They come before any task,
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
file wins.**
instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
---
@@ -125,14 +177,14 @@ People trust DeckCraft with their talks; a crash loses work. **This outranks fea
CLI, control channel and MCP do exactly the same thing.
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
tokens. **Never use Tauri**; egui only.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Never break wasm** (`cargo xtask wasm`).
## 5. Quality gates (before every commit)
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
to `main`.
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots
declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
## 6. Running and looking at the app
@@ -141,19 +193,18 @@ to `main`.
`{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}`
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods:
`crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations
in `plan/powerpoint/01-observed-ui.md`.
- **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
in `plan/powerpoint/01-observed-ui.md`. Otherwise verify the intended behaviour against the task and
committed documentation; the optional notebook is not a prerequisite.
- Headless: `deckcraft-cli render --sample --all --scale 1 out/`,
`deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`.
- MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app).
See `docs/mcp.md`.
- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own.
## 7. Planning
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
`plan/` is an optional gitignored notebook (status, execution notes, `powerpoint/` observations). Do not ask for it and do not stop when it is absent. The committed progress
docs:
| File | Holds |
+1 -25
View File
@@ -1,25 +1 @@
# DeckCraft — working notes for Claude
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
win over anything here.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
## Habits
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
`deckcraft-cli render`.
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+208 -78
View File
@@ -565,18 +565,42 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop",
"calloop 0.13.0",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
@@ -820,15 +844,24 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-audio"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
dependencies = [
"craft-media",
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -851,15 +884,26 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-metafile"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=8079878ae2b166c1ea8229e40eff7e08c916e532#8079878ae2b166c1ea8229e40eff7e08c916e532"
dependencies = [
"encoding_rs",
"image",
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -868,7 +912,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -888,7 +932,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -898,6 +942,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -911,6 +956,26 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -984,7 +1049,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1071,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1080,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1089,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1106,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1139,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1151,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1162,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1170,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,30 +1179,16 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-isobmff"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,15 +1198,13 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-audio",
"craft-media",
"deckcraft-av1",
"deckcraft-h264",
"deckcraft-hevc",
"deckcraft-isobmff",
"deckcraft-matroska",
"deckcraft-ogg",
"deckcraft-opus",
"deckcraft-vp9",
"image",
"log",
@@ -1163,18 +1212,9 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-metafile"
version = "0.15.0"
dependencies = [
"image",
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-model"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1184,17 +1224,9 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "deckcraft-ogg"
version = "0.15.0"
[[package]]
name = "deckcraft-opus"
version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1244,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,13 +1260,13 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-metafile",
"deckcraft-anim",
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-metafile",
"deckcraft-model",
"deckcraft-text",
"image",
@@ -1247,7 +1279,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1293,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1275,6 +1307,7 @@ dependencies = [
"deckcraft-render",
"deckcraft-text",
"egui",
"egui_extras",
"egui_kittest",
"log",
"serde",
@@ -1286,7 +1319,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,8 +1328,10 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"deckcraft-engine",
"deckcraft-ui-egui",
"eframe",
@@ -1507,19 +1542,35 @@ dependencies = [
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1587,6 +1638,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -2708,7 +2779,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3487,7 +3558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4403,8 +4474,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop",
"calloop-wayland-source",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"cursor-icon",
"libc",
"log",
@@ -4421,6 +4492,44 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4928,6 +5037,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5370,6 +5480,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -5980,7 +6116,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop",
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -6001,7 +6137,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -6177,7 +6313,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"serde_json",
]
@@ -6423,7 +6559,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6511,11 +6646,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+11 -12
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.15.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -14,7 +14,6 @@ repository = "https://github.com/storytold/deckcraft"
# internal
deckcraft-geom = { path = "crates/geom" }
deckcraft-color = { path = "crates/color" }
deckcraft-metafile = { path = "crates/metafile" }
deckcraft-model = { path = "crates/model" }
deckcraft-fonts = { path = "crates/fonts" }
deckcraft-text = { path = "crates/text" }
@@ -27,16 +26,12 @@ deckcraft-engine = { path = "crates/engine" }
deckcraft-mcp = { path = "crates/mcp" }
deckcraft-ui-egui = { path = "crates/ui-egui" }
deckcraft-media = { path = "crates/media" }
# codecs (ported from FilmCraft, same owner and licence)
# video codecs (ported from FilmCraft, same owner and licence)
deckcraft-bitstream = { path = "crates/bitstream" }
deckcraft-h264 = { path = "crates/h264" }
deckcraft-hevc = { path = "crates/hevc" }
deckcraft-vp9 = { path = "crates/vp9" }
deckcraft-av1 = { path = "crates/av1" }
deckcraft-isobmff = { path = "crates/isobmff" }
deckcraft-matroska = { path = "crates/matroska" }
deckcraft-ogg = { path = "crates/ogg" }
deckcraft-opus = { path = "crates/opus" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -59,11 +54,15 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-media and craft-audio share types and stay on one rev.
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", version = "=0.1.0", default-features = false }
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
# WMF, EMF and EMF+ reader.
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "8079878ae2b166c1ea8229e40eff7e08c916e532", version = "=0.1.0" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -109,7 +108,7 @@ codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", 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" }
+3 -2
View File
@@ -76,8 +76,9 @@ Code dependencies
- symphonia (audio decoding for MP3, AAC, ALAC, FLAC, Vorbis, WAV, AIFF, CAF) is used unmodified
under the Mozilla Public License 2.0; its source is available at
https://github.com/pdeljanov/Symphonia.
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
FilmCraft (same owner, MIT OR Apache-2.0).
- The H.264, HEVC, VP9 and AV1 crates under crates/ are ported from FilmCraft (same owner,
MIT OR Apache-2.0). The Opus decoder (craft-audio) and the MP4, Matroska and Ogg demuxers
(craft-media) come from craft-libs (same owner, MIT OR Apache-2.0).
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
+27
View File
@@ -2,7 +2,12 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -149,7 +154,22 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
@@ -321,7 +341,14 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | trivial | Opus and container code moved to craft-libs crates; parity scores unchanged |
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
| 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
+5 -1
View File
@@ -16,6 +16,10 @@ path = "src/main.rs"
[dependencies]
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -27,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
+151
View File
@@ -0,0 +1,151 @@
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
//! applying a command the page was told had failed.
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::sync::mpsc::{Receiver, TryRecvError};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
#[cfg(target_arch = "wasm32")]
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
struct Pending {
reply: Receiver<Value>,
state: Arc<AtomicU8>,
answered: bool,
}
impl Pending {
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "the app dropped the request"}))
}
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if !self.answered {
ControlRequest::abandon(&self.state);
}
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::channel;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use deckcraft_ui_egui::{ControlRequest, SlideApp};
use serde_json::json;
use wasm_bindgen_futures::JsFuture;
use super::{POLL_MS, Pending};
/// The app id in `window.<app>`.
const APP: &str = "deckcraft";
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
/// `window.deckcraft` keeps the functions it already has.
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
let (queue, requests) = channel::<ControlRequest>();
app.control_rx = Some(requests);
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
if queue.send(control).is_err() {
pending.answered = true;
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(async move {
loop {
if let Some(reply) = pending.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
/// sender gave up.
const TAKEN: u8 = 1;
const ABANDONED: u8 = 2;
fn pending() -> (Pending, ControlRequest) {
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
(Pending { reply, state: control.state.clone(), answered: false }, control)
}
#[test]
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
let (waiting, control) = pending();
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
assert!(!control.take(), "the app must not run a request the page was told had failed");
}
#[test]
fn an_answered_call_is_not_released() {
let (mut waiting, control) = pending();
assert!(control.take());
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
}
#[test]
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
let (mut waiting, control) = pending();
assert_eq!(waiting.poll(), None);
drop(control);
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
//! file server). The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `deckcraft:` prefix. This module is DeckCraft's part of it.
//!
//! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
//! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `deckcraft:ready` (to the host): sent once the app is running and listening; send a file now. DeckCraft
//! reads bytes, so it says `file: false` and a host sends `bytes`.
//! - `deckcraft:open` (from the host): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled.
//! - `deckcraft:save` (to the host): `{ name, saveAs, bytes }` (with the bytes as a transferred `ArrayBuffer` over `postMessage`) for
//! every file DeckCraft would otherwise download. The host stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
//! Save suggests on the desktop.
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
//! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
//! app couldn't start or refused a file of 2 GiB or larger.
//!
//! In host mode the app starts with an empty presentation instead of the sample, which gives way to
//! the first file the host sends; the host guards leaving the page, and `?author=` names the user
//! for comments.
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use deckcraft_ui_egui::{Services, SlideApp};
/// The app id in the host messages (`deckcraft:ready`).
const APP: &str = "deckcraft";
/// The name `craft-web-host` gives an open request that carries none (it cleans a host's name and
/// falls back to this): no name is no file, so the presentation opens untitled.
const NAMELESS: &str = "document";
/// The largest file a host may send. File › Open on the desktop refuses anything over 2 GiB.
/// On wasm32 a `Vec` cannot hold 2 GiB (`isize::MAX` is one byte less), so a file of exactly
/// 2 GiB is refused too, before its bytes are copied.
const MAX_OPEN_BYTES: f64 = 2_147_483_648.0;
/// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
/// Files the host sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
}
impl Bridge {
/// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
let host = Host::connect(APP, canvas)?;
// An opaque origin (a file:// page in some browsers) can't be a postMessage target.
if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
return None;
}
Some(Self { host: Rc::new(host), opened: Rc::default() })
}
/// Hand saved and exported files to the host instead of downloading them, and hear the files
/// it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Exports and Print are copies.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes, false) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new comments.
pub fn set_author(app: &mut SlideApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.author = author;
}
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// Before each frame: open the files the host sent.
pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened {
app.open_from_host(name.as_deref(), &bytes);
}
}
/// After each frame: tell the host when unsaved changes appear or go away (the crate does not
/// repeat a state it already reported).
pub fn report_dirty(&self, app: &SlideApp) {
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `deckcraft:open` and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) {
let opened = self.opened.clone();
let host = self.host.clone();
self.host.listen(move |message| {
let Message::Open { name, data } = message else { return };
if data.size() >= MAX_OPEN_BYTES {
log::warn!("host file {name} is 2 GiB or larger; not opened");
let _ = host.post_failed(&format!("{name} is 2 GiB or larger, which DeckCraft's web build cannot open"), None);
return;
}
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
return;
};
// No name is no file: the presentation opens untitled, never under an invented name.
let name = (name != NAMELESS).then_some(name);
opened.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
});
}
}
+8 -1
View File
@@ -9,15 +9,22 @@
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
//! that started the pick (a dismissed picker or a closed document drops the file);
//! - Save and Export trigger a browser download;
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
//! empty presentation instead of the sample deck; `?show` starts the slide show.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
//! for comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")]
+3
View File
@@ -251,6 +251,9 @@ impl Popup {
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
data.next = previous.next.clone();
}
if data.slides.is_none() {
data.slides = previous.slides.clone();
}
}
*target.borrow_mut() = Some(data);
*received.borrow_mut() = js_sys::Date::now();
+57 -7
View File
@@ -24,8 +24,18 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
// never the framed page.
let hosted = crate::presenter::token().is_none();
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
if query_has("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -44,22 +54,43 @@ pub fn start() {
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
if !q.contains("blank") {
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, cc.egui_ctx.clone());
}
let mut app = SlideApp::new(Session::new(), services);
// In host mode the parent sends the presentation to edit; no sample meanwhile.
if app_bridge.is_some() {
if let Err(e) = app.start_for_host() {
log::error!("new presentation: {e}");
}
} else if !query_has("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if q.contains("show") {
if query_has("show") {
app.start_show(0, false);
}
Ok(Box::new(WebShell { app, inbox }))
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut app, &author);
}
crate::api::install(&mut app, cc.egui_ctx.clone());
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -73,17 +104,36 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
/// `?author=` is free text.
fn query_has(key: &str) -> bool {
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
/// The parent page that keeps the files (`?host=parent`).
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
/// page sent first and telling it about unsaved changes after.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(bridge) = &self.bridge {
bridge.open_files(&mut self.app);
}
self.app.logic(ctx);
if let Some(bridge) = &self.bridge {
bridge.report_dirty(&self.app);
}
if self.app.show.is_none()
&& let Some(host) = self.app.services.presenter.as_mut()
{
+3 -1
View File
@@ -14,7 +14,9 @@ workspace = true
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
# the app; the egui 0.37 migration (#1350) dropped it from this list.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+20 -3
View File
@@ -148,7 +148,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
// Messages can embed document-controlled text (a part name, a relationship id): control
// characters other than tab are escaped and each continuation line is marked, so such text
// cannot start a line that looks like a record of its own.
let mut text = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => text.push_str("\n | "),
'\t' => text.push(c),
c if c.is_control() => text.extend(c.escape_default()),
c => text.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -446,8 +458,13 @@ mod tests {
),
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
// A multi-line message keeps its lines, each continuation marked; the record still ends in
// exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
// Injected line breaks and escape sequences cannot forge a record.
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
assert_eq!(line.matches('\n').count(), 2);
assert!(!line.contains('\r') && !line.contains('\x1b'));
}
#[test]
+52 -5
View File
@@ -51,6 +51,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
// The window is closing for good: a ready update's helper starts now and installs it once
// the process has exited.
self.0.start_pending_update();
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
@@ -103,7 +106,13 @@ fn save_prefs(app: &SlideApp) {
}
}
fn services() -> Services {
/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
fn services(native_wayland: bool) -> Services {
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
let pasteboard = !native_wayland;
Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
@@ -126,9 +135,9 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
.is_none()
@@ -137,6 +146,14 @@ fn services() -> Services {
}
}
/// Displays winit lists for `window`, counting an output Wayland lists twice once. `None` when it
/// lists none: Presenter View then stays as it was.
#[cfg(all(unix, not(target_os = "macos")))]
fn display_count(window: &winit::window::Window) -> Option<usize> {
let n = window.available_monitors().collect::<std::collections::BTreeSet<_>>().len();
(n > 0).then_some(n)
}
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
@@ -247,6 +264,11 @@ fn main() -> eframe::Result {
false
}
};
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
#[cfg(all(unix, not(target_os = "macos")))]
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
#[cfg(not(all(unix, not(target_os = "macos"))))]
let native_wayland = false;
#[cfg(all(unix, not(target_os = "macos")))]
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -268,7 +290,12 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
let mut app = SlideApp::new(Session::new(), services(native_wayland));
// Presenter View asks winit how many displays there are when a show starts.
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(window) = cc.winit_window().cloned() {
app.services.displays = Some(Box::new(move || display_count(&window)));
}
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
@@ -335,6 +362,26 @@ mod tests {
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
/// lost text that went out as HTML. Keep the OS clipboard on.
#[test]
fn eframe_uses_the_os_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
}
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
/// it carries HTML and pictures beside egui's text.
#[test]
fn the_pasteboard_stays_off_native_wayland() {
let s = services(true);
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
let s = services(false);
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
}
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
+59 -6
View File
@@ -118,6 +118,8 @@ enum Output {
grain: FilmGrainParams,
apply_film_grain: bool,
pts: i64,
/// What copying the picture out costs, in the reference's own format.
bytes: u64,
},
}
@@ -132,7 +134,7 @@ impl Output {
fn take(self) -> Result<Picture> {
match self {
Output::Decoded(s) => s.wait().map(|p| Arc::try_unwrap(p).unwrap_or_else(|p| (*p).clone())),
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts } => {
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts, .. } => {
let rf = slot.wait()?;
Ok(output_picture(&seq, &rf, width, height, &grain, apply_film_grain, pts))
}
@@ -379,8 +381,8 @@ impl Decoder {
// Output policy (7.18.1 note): with scalability, show one frame per temporal unit, the
// highest spatial layer present.
let scalable = self.seq.as_ref().is_some_and(|s| s.op_idc != 0);
if !scalable {
self.out.extend(out.into_iter().map(|(o, _, _)| o));
let kept: Vec<Output> = if !scalable {
out.into_iter().map(|(o, _, _)| o).collect()
} else {
let mut last: Option<(usize, u32)> = None;
let mut sel: Vec<Output> = Vec::new();
@@ -400,8 +402,27 @@ impl Decoder {
}
}
}
self.out.extend(sel);
sel
};
// Every repeat is copied out whole, so the repeats this call keeps (after the layer
// selection) must not queue more than half the decoder budget; the first one over it and
// everything after is dropped.
let mut r = r;
let mut repeat_bytes = 0u64;
let mut ok = Vec::with_capacity(kept.len());
for o in kept {
if let Output::Existing { bytes, .. } = &o {
repeat_bytes = repeat_bytes.saturating_add(*bytes);
if repeat_bytes > self.memory_budget / 2 {
if r.is_ok() {
r = Err(Error::Invalid("repeated frame outputs exceed the decoder memory budget"));
}
break;
}
}
ok.push(o);
}
self.out.extend(ok);
r
}
@@ -425,6 +446,8 @@ impl Decoder {
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
let mut pos = 0;
while pos < data.len() {
let obu = parse_obu_unit(data, pos)?;
@@ -444,9 +467,10 @@ impl Decoder {
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
if existing >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
existing += usize::from(matches!(o, Output::Existing { .. }));
out.push((o, tu, spatial_id));
}
}
@@ -500,6 +524,15 @@ impl Decoder {
let idx = fh.frame_to_show_map_idx;
let slot = self.ref_frames[idx].clone().ok_or(Error::Invalid("show_existing_frame of an empty slot"))?;
let info = self.refs[idx].clone();
// Sized in the reference's own format: a new sequence header may differ from it.
let shape = deckcraft_bitstream::budget::PictureShape {
width: u64::from(info.upscaled_width),
height: u64::from(info.frame_height),
shift_x: u32::from(info.subsampling_x),
shift_y: u32::from(info.subsampling_y),
chroma_planes: if info.mono_chrome { 0 } else { 2 },
bytes_per_sample: 2,
};
let o = Output::Existing {
slot: slot.clone(),
seq: seq.clone(),
@@ -508,6 +541,7 @@ impl Decoder {
grain: fh.film_grain.clone(),
apply_film_grain: self.apply_film_grain,
pts: self.pts,
bytes: shape.bytes().unwrap_or(u64::MAX),
};
if fh.frame_type == KEY_FRAME as u8 {
// reference frame loading process (7.21) then refresh every slot (7.20)
@@ -626,6 +660,9 @@ impl Decoder {
ri.lf_deltas = fh.lf.deltas;
ri.seg_features = fh.seg.features;
ri.grain = fh.film_grain.clone();
ri.subsampling_x = job.seq.color.subsampling_x;
ri.subsampling_y = job.seq.color.subsampling_y;
ri.mono_chrome = job.seq.color.mono_chrome;
self.ref_frames[i] = Some(job.slot.clone());
}
}
@@ -774,6 +811,14 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
Ok((rf, pic))
}
/// Bytes all parallel tile states may hold of whole-frame CDEF and restoration metadata; past it
/// the tiles decode one after another on the frame's own metadata.
const TILE_METADATA_BUDGET: usize = 256 << 20;
fn tile_metadata_fits(tiles: usize, per_tile: usize) -> bool {
tiles.checked_mul(per_tile).is_some_and(|n| n <= TILE_METADATA_BUDGET)
}
/// Decode the tiles of a frame (tile number, data), in parallel when several; returns the final
/// CDFs of tile context_update_tile_id.
fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize, Vec<u8>)]) -> Result<Option<Box<Cdfs>>> {
@@ -787,7 +832,7 @@ fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize,
};
// Intra block copy reads the current frame through the tile's own buffer: keep whole-frame
// buffers (tiles one after another) for it.
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc {
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
let mut states: Vec<TileState> = tiles
.iter()
.map(|(num, _)| {
@@ -1072,6 +1117,14 @@ mod guard_tests {
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn tile_metadata_copies_are_budgeted() {
assert!(tile_metadata_fits(64, 1 << 20));
assert!(!tile_metadata_fits(4096, 1 << 20));
assert!(!tile_metadata_fits(usize::MAX, 2));
}
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
fn color(mono: bool) -> crate::header::ColorConfig {
+4
View File
@@ -360,6 +360,10 @@ pub struct RefInfo {
pub lf_deltas: LoopFilterDeltas,
pub seg_features: SegmentationFeatures,
pub grain: FilmGrainParams,
/// The colour format the frame was decoded in (a later sequence header may change it).
pub subsampling_x: u8,
pub subsampling_y: u8,
pub mono_chrome: bool,
}
/// A parsed uncompressed frame header with its derived variables.
+7
View File
@@ -253,6 +253,13 @@ impl FrameState {
self.lr = t.lr;
}
/// Bytes of CDEF and loop-restoration metadata each parallel tile state copies whole.
pub fn tile_metadata_bytes(&self) -> usize {
let lr = &self.lr;
// 1 (type) + 6 (Wiener) + 1 (SGR set) + 2 (SGR weights) bytes per unit.
self.cdef.len() + lr.lr_type.iter().map(|v| v.len() * 10).sum::<usize>()
}
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
/// on the right / bottom frame edge also cover the allocation margins.
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
+11 -7
View File
@@ -466,22 +466,26 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
let ids = targets(s, p)?;
let mut ids = targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "no shapes selected"));
}
// A shape named twice is edited once.
let mut seen = std::collections::HashSet::new();
ids.retain(|id| seen.insert(*id));
s.edit(|doc, sel| {
let boxes: Vec<(ShapeId, Xfrm)> = {
let st_shapes: Vec<deckcraft_model::Shape> =
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
};
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
.iter()
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
.collect();
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
let mut n = 0;
for id in &ids {
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
if sh.xfrm.is_none() {
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
sh.xfrm = boxes.get(id).copied();
}
f(sh)?;
n += 1;
+131 -9
View File
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
Some(Packed { bytes, content_type, crop: new_crop })
}
/// Picture shapes in `shapes` (groups included) that `want` accepts.
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
fn pictures_mut<'a>(
shapes: &'a mut [Shape],
want: &dyn Fn(ShapeId) -> bool,
out: &mut Vec<(&'a mut Shape, f64, f64)>,
to_slide: deckcraft_geom::Affine,
depth: usize,
) {
if depth > 64 {
return;
}
for sh in shapes {
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
out.push(sh);
} else if let Some(ch) = sh.children_mut() {
pictures_mut(ch, want, out, depth + 1);
let Some(xfrm) = sh.xfrm else { continue };
let local_to_slide = to_slide * xfrm.affine();
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
continue;
}
if let ShapeKind::Group { children, child } = &mut sh.kind {
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
pictures_mut(children, want, out, child_to_slide, depth + 1);
}
}
}
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
let Some(slide) = doc.slides.get(i).cloned() else { continue };
let mut slide = slide.as_ref().clone();
let mut pics = vec![];
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
let mut touched = false;
for sh in pics {
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
for (sh, w, h) in pics {
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
let PictureFill { media, crop, .. } = fill.clone();
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
@@ -232,6 +246,114 @@ mod tests {
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
}
#[test]
fn grouped_pictures_use_their_displayed_size() {
let mut s = Session::with_new();
let original = png(600, 600);
s.edit(|doc, _| {
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_101),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_102),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_103),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
assert!(
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
"the under-target original media must remain untouched"
);
}
#[test]
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
let mut s = Session::with_new();
let original = png(2000, 1000);
s.edit(|doc, _| {
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_111),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_112),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_113),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (960, 480));
}
#[test]
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
let mut s = Session::with_new();
let original = png(800, 800);
s.edit(|doc, _| {
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(Shape {
id: ShapeId(70_104),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
..Default::default()
});
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
assert_eq!(fill.crop, [0.0; 4]);
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (96, 192));
}
#[test]
fn pack_deletes_cropped_areas_or_keeps_them() {
let src = png(400, 400);
+3 -3
View File
@@ -1,17 +1,17 @@
use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
master_index, ok, opt_usize_param, rect_param, str_param,
};
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
usize_param(p, "layout")
opt_usize_param(p, "layout", "view.slideMaster")?
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
+45 -16
View File
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
let mut shapes: Vec<Shape> = st
.selection
.shapes
.iter()
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
detach_on_slide(&st.doc, &st.selection, &mut shapes);
let mids = crate::media_refs::shapes_media(&shapes);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
colors: ctx.master.theme.colors.clone(),
color_map: ctx.color_map.clone(),
fonts: ctx.master.theme.fonts.clone(),
});
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
Ok(plain)
}
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
if sel.target == crate::Target::Slides
&& let Some(ctx) = super::text::ctx_for(doc, sel)
{
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
}
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
@@ -319,6 +338,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return s.edit(|doc, sel| {
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
if sel.target != crate::Target::Slides {
for sh in &mut shapes {
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
sh.clone_from(original);
}
}
}
reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let (mut taken, mut count) = super::current_names(doc, sel);
@@ -340,7 +366,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
x.x += off;
x.y += off;
}
// A pasted placeholder becomes a regular shape keeping its look.
// A placeholder pasted on a slide becomes a regular shape. One copied from a slide
// already has its look; see `copy_inner`.
if sh.ph.is_some() && sel.target == crate::Target::Slides {
sh.ph = None;
}
@@ -404,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
"text" | "keepTextOnly" => paste_text(s, &json!({})),
"keepSource" | "keepSourceFormatting" => {
let saved = s.clipboard.clone();
if let Some((scheme, fonts)) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
if let Some(theme) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, theme);
bake_theme(&mut s.clipboard.placeholders, theme);
}
let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes;
@@ -420,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
/// shapes keep their look under another theme.
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
if depth > 256 {
return;
}
@@ -430,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
if o.get("kind").and_then(Value::as_str) == Some("scheme")
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
&& slot != deckcraft_color::SchemeSlot::PhClr
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
&& let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
{
o.clear();
o.insert("kind".into(), json!("rgb"));
@@ -438,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
return;
}
for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1);
rec(x, theme, depth + 1);
}
}
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) {
Some("mj") => &fonts.major,
Some("mn") => &fonts.minor,
Some("mj") => &theme.fonts.major,
Some("mn") => &theme.fonts.minor,
_ => return,
};
let face = match t.get(3..) {
@@ -463,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
}
for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0);
rec(&mut v, theme, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked;
}
@@ -476,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
if clip.shapes.is_empty() {
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
}
if let Some((scheme, fonts)) = &clip.source_theme {
bake_theme(&mut clip.shapes, scheme, fonts);
if let Some(theme) = &clip.source_theme {
bake_theme(&mut clip.shapes, theme);
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
@@ -533,6 +561,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
detach_on_slide(doc, sel, &mut copies);
// Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset.
let (dx, dy) = last
+33 -5
View File
@@ -253,8 +253,15 @@ pub struct LoadedFile {
#[cfg(not(target_arch = "wasm32"))]
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let (doc, damaged) = open_checked(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
loaded_file(path, &bytes)
}
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
/// that name like a file opened on the desktop.
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
let (doc, damaged) = open_checked(path, bytes)?;
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
}
@@ -345,6 +352,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
@@ -359,7 +367,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
"handouts" => {
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
}
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
}
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
@@ -390,6 +404,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
@@ -405,7 +420,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
if seen.insert(i) {
out.push(i);
}
}
@@ -462,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
}
}
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
/// page has the bytes.
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
let st = s.doc_mut()?;
st.saved_doc = st.doc.clone();
if path.is_some() {
@@ -769,6 +787,16 @@ mod tests {
use super::{parse_slide_range, write_file};
#[test]
fn broad_and_repeated_slide_ranges_expand_once() {
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
let text = vec!["1-100000"; 200].join(",");
let v = parse_slide_range(&text, 100_000).unwrap();
assert_eq!(v.len(), 100_000);
assert!(v.windows(2).all(|w| w[0] < w[1]));
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
}
#[test]
fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err());
+9 -10
View File
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
"thaiDist" => Align::ThaiDistributed,
_ => Align::Left,
};
align_to(s, a)
@@ -55,6 +56,7 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
@@ -85,6 +87,7 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
@@ -117,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let a = str_param(p, "align").map(|a| match a {
"center" => Align::Center,
"right" => Align::Right,
"justify" => Align::Justify,
"distributed" => Align::Distributed,
_ => Align::Left,
});
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
let ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore");
@@ -179,12 +176,13 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
Ok(match s {
"left" | "l" => Align::Left,
"center" | "ctr" => Align::Center,
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"justLow" | "justifyLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
"thaiDist" => Align::ThaiDistributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
})
}
@@ -196,6 +194,7 @@ fn align_name(a: Align) -> &'static str {
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
Align::ThaiDistributed => "thaiDist",
}
}
+144 -6
View File
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
let opts = album_opts(p)?;
let items = album_items(p)?;
let (id, ids) = album_target(s, p)?;
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
// delete what it left out, so refuse rather than lose it.
let st = s.doc()?;
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
}
commit_album(s, items, opts, Some((id, ids)))
}
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
}
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
/// transition, animations, a colour map or tags.
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
!s.shapes.is_empty()
|| !s.notes.text().trim().is_empty()
|| !s.comments.is_empty()
|| s.background.is_some()
|| s.transition.is_some()
|| !s.animations.is_empty()
|| s.color_map.is_some()
|| !s.tags.is_empty()
}
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
}
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
/// name one large picture many times.
///
/// The flag is true when something was left out for a limit.
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
let mut items = Vec::new();
for slide in slides {
let mut total = 0usize;
let mut omitted = false;
'slides: for slide in slides {
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
if pics.is_empty() {
let text = slide
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
.find(|t| !t.trim().is_empty())
.unwrap_or_default();
if !text.trim().is_empty() {
if items.len() >= ALBUM_MAX_ITEMS {
omitted = true;
break 'slides;
}
items.push(json!({"text": text.trim()}));
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
// Past the item limit, a slide with other content would be deleted by a replace.
omitted = true;
break 'slides;
}
continue;
}
for sh in pics {
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let media = doc.media(fill.media);
let size = media.map_or(0, |m| m.data.len());
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
omitted = true;
break 'slides;
}
total += size;
let name = if !sh.descr.is_empty() {
sh.descr.clone()
} else {
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
}));
}
}
items
(items, omitted)
}
/// The slides of album `ids` as the editor would seed them.
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides: Vec<&deckcraft_model::Slide> =
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
let slides = album_slides(st, &ids);
let (items, _) = items_from_slides(&st.doc, &slides);
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
let inferred = infer_album(&slides, &theme_now);
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items_from_slides(&st.doc, &slides),
"items": items,
}))
}
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
s.execute("slide.go", &json!({"index": first}))?;
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
}
#[cfg(test)]
mod seed_tests {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, defaults};
use super::*;
#[test]
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
let mut p = defaults::new_presentation(None);
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
let pic = |k: u32| Shape {
id: ShapeId(10_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
let (items, omitted) = items_from_slides(&p, &slides);
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
assert!(omitted);
let few: Vec<&deckcraft_model::Slide> = Vec::new();
assert!(!items_from_slides(&p, &few).1);
}
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
/// delete what the seed left out.
#[test]
fn editing_an_album_the_editor_cannot_hold_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
doc.slides.push(Arc::new(extra));
let n = doc.slides.len();
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
}
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
#[test]
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
#[test]
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes.clear();
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
#[test]
fn slide_level_settings_count_as_slide_content() {
use deckcraft_model::Slide;
assert!(!has_slide_content(&Slide::default()));
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
}
}
+42 -1
View File
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
list.insert(at + k, sh);
}
list.retain(|x| !ids.contains(&x.id));
sel.shapes = new_ids.clone();
let _ = list;
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
let sl = std::sync::Arc::make_mut(sl);
sl.animations.retain(|a| !ids.contains(&a.shape));
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
a.trigger = None;
}
}
sel.shapes = new_ids.clone();
Ok(())
})?;
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
@@ -338,4 +347,36 @@ mod tests {
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
}
#[test]
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
let mut s = Session::with_new();
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
.as_u64()
.expect("id") as u32;
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
.as_u64()
.expect("id") as u32;
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
.as_u64()
.expect("id") as u32;
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
s.execute("edit.undo", &json!({})).expect("undo merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
assert_eq!(animations[0].shape.0, second);
assert_eq!(animations[1].shape.0, unrelated);
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
}
}
}
+8 -1
View File
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
let e = Effects {
outer_shadow: lk.shadow.map(shadow),
soft_edge: lk.soft_edge,
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
reflection: lk.reflection.then_some(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
}),
bevel: lk.bevel.map(str::to_string),
..Default::default()
};
+437 -4
View File
@@ -6,16 +6,155 @@
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
/// Scale physical measurements in imported content to the destination slide's coordinate space.
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
let unit = (sx * sy).sqrt();
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
*v *= sx;
}
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
*v *= sy;
}
scale_list(&mut t.list_style, sx, unit);
for p in &mut t.paragraphs {
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
*v *= sx;
}
if let Some(stops) = &mut p.props.tabs {
for stop in stops {
stop.pos *= sx;
}
}
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut p.props.bullet_size_points {
*v *= unit;
}
scale_run(&mut p.def_props, unit);
scale_run(&mut p.end_props, unit);
for r in &mut p.runs {
scale_run(&mut r.props, unit);
}
}
}
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
for level in list.levels.iter_mut().flatten() {
for v in
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
{
*v *= sx;
}
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut level.para.bullet_size_points {
*v *= unit;
}
scale_run(&mut level.run, unit);
}
}
fn scale_run(run: &mut RunProps, factor: f64) {
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
*v *= factor;
}
}
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
if depth > 64 {
return;
}
let unit = (sx * sy).sqrt();
for shape in shapes {
if let Some(x) = &mut shape.xfrm {
x.x *= sx;
x.w *= sx;
x.y *= sy;
x.h *= sy;
}
if let Some(text) = &mut shape.text {
scale_text(text, sx, sy);
}
match &mut shape.kind {
ShapeKind::Table(table) => {
for col in &mut table.cols {
*col *= sx;
}
for row in &mut table.rows {
row.height *= sy;
for cell in &mut row.cells {
if let Some(margins) = &mut cell.margins {
if let Some(v) = margins.get_mut(0) {
*v *= sx;
}
if let Some(v) = margins.get_mut(1) {
*v *= sx;
}
if let Some(v) = margins.get_mut(2) {
*v *= sy;
}
if let Some(v) = margins.get_mut(3) {
*v *= sy;
}
}
scale_text(&mut cell.text, sx, sy);
}
}
}
ShapeKind::Group { children, child } => {
child.x *= sx;
child.w *= sx;
child.y *= sy;
child.h *= sy;
scale_shapes(children, sx, sy, depth + 1);
}
_ => {}
}
if let Some(effects) = &mut shape.effects {
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
shadow.blur *= unit;
shadow.dist *= unit;
}
if let Some(glow) = &mut effects.glow {
glow.radius *= unit;
}
if let Some(reflection) = &mut effects.reflection {
reflection.blur *= unit;
reflection.dist *= unit;
}
}
}
}
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
scale_shapes(&mut master.shapes, sx, sy, 0);
let unit = (sx * sy).sqrt();
scale_list(&mut master.title_style, sx, unit);
scale_list(&mut master.body_style, sx, unit);
scale_list(&mut master.other_style, sx, unit);
for layout in &mut master.layouts {
scale_shapes(&mut layout.shapes, sx, sy, 0);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
.unwrap_or_default()
}
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind
&& seen.insert(table.style.to_ascii_lowercase())
{
styles.push(table.style.clone());
}
collect_table_styles(shape.children(), styles, seen, depth + 1);
}
}
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &mut shape.kind
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
{
table.style = style.clone();
}
if let Some(children) = shape.children_mut() {
remap_table_styles(children, styles, depth + 1);
}
}
}
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
}
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
/// existing destination tables continue to resolve to their original definitions.
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
let mut source_defs = HashMap::new();
let mut source_ids = HashSet::new();
for (index, style) in src.table_styles.iter().enumerate() {
let key = style.id.to_ascii_lowercase();
source_defs.entry(key.clone()).or_insert(index);
source_ids.insert(key);
}
let mut destination_defs = HashMap::new();
for (index, style) in doc.table_styles.iter().enumerate() {
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
}
for id in used {
let key = id.to_ascii_lowercase();
if map.contains_key(&key) {
continue;
}
let Some(source_index) = source_defs.get(&key).copied() else { continue };
let Some(source) = src.table_styles.get(source_index) else { continue };
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
let mut imported = source.clone();
if let Some(existing) = destination {
if same_table_style(existing, source) {
map.insert(key, existing.id.clone());
continue;
}
let candidate = loop {
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
let key = candidate.to_ascii_lowercase();
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
break candidate;
}
};
// The definition's own XML names it too, and PPTX export writes that XML as is.
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
imported.id.clone_from(&candidate);
map.insert(key, candidate);
} else {
map.insert(key, imported.id.clone());
}
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
doc.table_styles.push(imported);
}
Ok(map)
}
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
let src = super::file::open_presentation(&name, &bytes)?;
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
let source_size = src.slide_size;
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
let mut used_table_styles = Vec::new();
let mut seen_table_styles = HashSet::new();
for &i in &picked {
if let Some(slide) = src.slides.get(i) {
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
}
}
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
let scale = |source: f64, destination: f64| {
let ratio = destination / source;
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
};
let sx = scale(source_size.width, doc.slide_size.width);
let sy = scale(source_size.height, doc.slide_size.height);
// Media: what the slides use is copied under fresh ids.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Keep Source Formatting: bring each source master used, once, with fresh ids.
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
scale_master(&mut m, sx, sy);
doc.masters.push(Arc::new(m));
}
}
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let Some(from) = src.slides.get(i) else { continue };
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
scale_shapes(&mut sl.shapes, sx, sy, 0);
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
#[cfg(test)]
mod tests {
use deckcraft_geom::{Size, Xfrm};
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
use serde_json::json;
use super::*;
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
let mut p = deckcraft_model::defaults::new_presentation(None);
let raw = format!(
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
);
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
let group = deckcraft_model::Shape {
id: ShapeId(p.alloc_id().expect("group id")),
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
..Default::default()
};
let slide = p.slides.first_mut().expect("default slide");
Arc::make_mut(slide).shapes.push(group);
p
}
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind {
return Some(table.style.clone());
}
if let Some(style) = first_table_style(shape.children()) {
return Some(style);
}
}
None
}
#[test]
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
for (name, bytes) in [
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
] {
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
session.execute("edit.undo", &json!({})).expect("undo reuse");
let doc = &session.doc().expect("doc").doc;
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
session.execute("edit.redo", &json!({})).expect("redo reuse");
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
}
}
#[test]
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
let source_bytes = deckcraft_format::save(&source).expect("save source");
let mut identical = Session::with_new();
identical
.edit(|doc, _| {
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
Ok(())
})
.expect("seed identical definition");
identical
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse identical definition");
let doc = &identical.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let mut conflicting = Session::with_new();
conflicting
.edit(|doc, _| {
let mut own = source.table_styles.first().cloned().unwrap_or_default();
own.raw = own.raw.replace("FF0000", "0000FF");
doc.table_styles.push(own);
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let id = ShapeId(doc.alloc_id().expect("destination table id"));
if let Some(slide) = doc.slides.first_mut() {
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
}
Ok(())
})
.expect("seed conflicting definition");
conflicting
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse conflicting definition");
let doc = &conflicting.doc().expect("doc").doc;
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
assert_ne!(imported_style, CUSTOM_STYLE);
assert!(
doc.table_styles.iter().any(|style| style.id == imported_style
&& style.raw.contains("FF0000")
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
}
#[test]
fn reused_slide_content_fits_destination_size() {
let mut src = defaults::new_presentation(None);
src.slide_size = Size::new(960.0, 540.0);
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
let mut text = TextBody::from_text("scaled");
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(24.0);
}
slide.shapes = vec![
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
Shape {
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
text: Some(text),
kind: ShapeKind::Table({
let mut table = Table::new(2, 2, 400.0, 40.0);
table.rows[0].cells[0].text = TextBody::from_text("cell");
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(18.0);
}
table
}),
..Default::default()
},
Shape {
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
kind: ShapeKind::Group {
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
},
..Default::default()
},
];
}
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
if let Some(layout) = master.layouts.first_mut() {
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
}
}
let bytes = deckcraft_format::save(&src).expect("source save");
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session
.edit(|doc, _| {
doc.slide_size = Size::new(480.0, 270.0);
Ok(())
})
.expect("destination size");
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
let reused = doc.slides.get(1).expect("reused slide");
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
let table_shape = reused.shapes.get(1).expect("table");
let table_box = table_shape.xfrm.expect("table transform");
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
let ShapeKind::Table(table) = &table_shape.kind else { return };
assert_eq!(table.cols, vec![100.0, 100.0]);
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(font_size, Some(12.0));
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(cell_size, Some(9.0));
let group = reused.shapes.get(2).expect("group");
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
let ShapeKind::Group { children, child } = &group.kind else { return };
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
let imported_master = doc.masters.last().expect("imported master");
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
assert_ne!(reused.id, slide_id);
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
}
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
+91 -7
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(v);
}
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
continue;
@@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
// The result is built in one pass; the text is not edited in place for every match.
let mut out = String::new();
let (mut n, mut at, mut copied) = (0, 0, 0);
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
// The character before the match as the edited text would have it.
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
out.push_str(text.get(copied..i).unwrap_or_default());
out.push_str(to);
n += 1;
at = i + to.len();
(at, copied) = (end, end);
if !all {
break;
}
}
if n > 0 {
out.push_str(text.get(copied..).unwrap_or_default());
*text = out;
}
n
}
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
/// The replacement as it ran before: edited in place for every match.
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
#[test]
fn replace_word_matches_the_in_place_version() {
let cases = [
("a a b a", "a", "x", true),
("aa a. a", "a", "bb", true),
("a-a -a-", "-", "ab", true),
("foo. foo.", "foo.", "", true),
("it's it", "it", "so", true),
("x.x. x.", "x.", "y", true),
("a a a", "a", "b", false),
("none", "z", "q", true),
("é é", "é", "e", true),
];
for (text, from, to, all) in cases {
let (mut a, mut b) = (text.to_string(), text.to_string());
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
}
}
use super::*;
use serde_json::json;
@@ -812,6 +862,40 @@ mod tests {
assert_eq!(here["text"], "on first");
}
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+8
View File
@@ -135,6 +135,14 @@ fn eye_show_all_and_hide_all() {
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
}
#[test]
fn lock_group_with_its_child_targeted_leaves_child_locked() {
let (mut s, ids) = deck();
let (d, g) = (ids[3], ids[7]);
s.execute("shape.lock", &json!({"ids": [g, d, d]})).unwrap();
assert!(flags(&s, g).1 && flags(&s, d).1);
}
#[test]
fn lock_cascades_through_groups_and_undoes() {
let (mut s, ids) = deck();
+21 -2
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
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,
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
@@ -374,6 +374,25 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
// A targeted shape inside another targeted group is reached through the group: editing it again
// would toggle it back.
let mut ids = super::targets(s, p)?;
if ids.len() > 1 {
let doc = s.doc()?;
// Walk each distinct group once, however often `ids` repeats it.
let mut once = std::collections::HashSet::new();
ids.retain(|id| once.insert(*id));
let mut inside = std::collections::HashSet::new();
for id in &ids {
if let Some(g) = doc.shape(*id) {
deckcraft_model::walk(g.children(), &mut |c, _| {
inside.insert(c.id);
});
}
}
ids.retain(|id| !inside.contains(id));
}
let p = &json!({"ids": ids, "locked": p.get("locked")});
edit_shapes(s, p, "shape.lock", |sh| {
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
+16 -8
View File
@@ -1,11 +1,11 @@
use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`.
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
@@ -371,9 +372,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
@@ -467,7 +468,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
let mut r = e.reflection.take().unwrap_or(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
});
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
@@ -548,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
}
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41);
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone());
+16 -5
View File
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
// In slide space, as shape.connect uses them, for a shape inside a group too.
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
// glues to grouped shapes), its sites where the group draws them.
let site_list = |id| {
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
};
let (a, b) = (site_list(from)?, site_list(to)?);
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
};
let (p0, p1) = (a[ia], b[ib]);
let id = match id_param(p, "id", "shape.connect")? {
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
// inside a group would be drawn in the group's space and never follow its shapes.
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
}
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
@@ -93,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
a.iter()
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
.filter(|q| q.x.is_finite() && q.y.is_finite())
.take(20_000)
.take(crate::tools::MAX_GESTURE_POINTS)
.collect()
})
.unwrap_or_default();
+7
View File
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_geometry(s, p, "shape.flip", |sh| {
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
// also inside a group: ungrouping would hand the flip to the graphic.
let mut graphic = false;
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
if graphic {
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
}
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
+44 -1
View File
@@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
for sec in &mut doc.sections {
sec.slides.retain(|x| !ids.contains(x));
}
for show in &mut doc.custom_shows {
show.slides.retain(|x| !ids.contains(x));
}
sel.slide = first.min(doc.slides.len().saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
@@ -405,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
};
s.select(|doc, sel| {
if add {
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids {
if !sel.slides.contains(i) {
if have.insert(*i) {
sel.slides.push(*i);
}
}
@@ -777,6 +781,45 @@ mod tests {
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
}
#[test]
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
let (mut s, ids) = deck(3);
let shows = vec![
deckcraft_model::CustomShow {
name: "First".into(),
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
},
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
];
s.edit(|doc, _| {
doc.custom_shows = shows;
Ok(())
})
.expect("set custom shows");
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
let shows = &s.doc().expect("doc").doc.custom_shows;
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(shows[2].slides.is_empty());
s.execute("edit.undo", &json!({})).expect("undo deletion");
assert_eq!(order(&s), ids);
assert_eq!(
s.doc().expect("doc").doc.custom_shows[0].slides,
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
);
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
s.execute("edit.redo", &json!({})).expect("redo deletion");
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(reopened.custom_shows[2].slides.is_empty());
}
#[test]
fn move_from_to_moves_one_slide() {
let (mut s, ids) = deck(4);
+43 -21
View File
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
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))),
// Exactly [row, col]: an entry with more is as malformed as one with less.
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
match v.as_array().map(Vec::as_slice) {
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// At most this many `cells` in one table cell command.
const MAX_CELLS: usize = 100_000;
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
/// caret, else every cell.
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = p
.get("cells")
.and_then(Value::as_array)
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
// dropped or read as absent (which would act on the selection or the whole table).
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
}
Some(Value::Array(a)) => Some(
a.iter()
.map(|c| {
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
})
.collect::<Result<_>>()?,
),
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
};
let st = s.doc()?;
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
let cmd = cmd.to_string();
Ok(move |t: &Table| match (&cells, sel) {
(Some(c), _) => c.clone(),
// Each listed cell must be in the table, so a command never reports success for a cell
// it could not edit.
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
None => Ok(c.clone()),
},
(None, Some((a, b, range))) => {
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
}
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellFill")?;
with_table(s, p, "table.cellFill", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
cell.fill.clone_from(&fill);
}
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
// "none" clears to no line, so the table style's own lines don't show through either.
let line = if which == "none" { Line::none() } else { line };
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.borders")?;
with_table(s, p, "table.borders", move |t| {
let list = targets(t);
let list = targets(t)?;
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
else {
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = match side {
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
};
if let Some((r, c, side)) = n
&& let Some(cell) = t.cell_mut(r, c)
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
};
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellMargins")?;
with_table(s, p, "table.cellMargins", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
cell.margins = Some(m);
}
@@ -560,9 +582,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
}),
};
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellAlign")?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
if anchor.is_some() {
cell.anchor = anchor;
+112 -18
View File
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
let mut media = Media::new(cur, rev);
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
let details = slide_details(cur, a, rev, b, &mut media);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
});
}
}
// Each revised slide's open match (the first pair naming it).
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
for p in &pairs {
rev_to_cur.entry(p.1).or_insert(p.0);
}
// After the open slide that matches the nearest matched revised slide before it.
let mut at = 0;
for (j, s) in rev.slides.iter().enumerate() {
if let Some(i) = rev_to_cur.get(&j) {
at = i + 1;
}
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
/// show one picture many times, and its bytes are compared once per pair.
struct Media<'a> {
cur: HashMap<MediaId, &'a [u8]>,
rev: HashMap<MediaId, &'a [u8]>,
same: HashMap<(MediaId, MediaId), bool>,
}
impl<'a> Media<'a> {
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
// The first item with an id wins, as in `Presentation::media`.
let index = |p: &'a Presentation| {
let mut m = HashMap::new();
for x in &p.media {
m.entry(x.id).or_insert(x.data.as_slice());
}
m
};
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
}
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
match (a, b) {
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
_ => ba.is_some() || bb.is_some(),
}
}
}
/// A shape without its ids (shape and media), for comparing across decks.
@@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
}
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
let pic = |s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => Some(fill.media),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
if media.differ(pic(a), pic(b)) {
what.push("picture");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
for (k, x) in b.shapes.iter().enumerate() {
by_id.entry(x.id).or_insert(k);
if !x.name.is_empty() {
by_name.entry(x.name.as_str()).or_default().0.push(k);
}
}
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
let k = by_id.get(&sa.id).copied().or_else(|| {
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
while ks.get(*next).is_some_and(|k| used.contains(k)) {
*next += 1;
}
ks.get(*next).copied()
});
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
}
}
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone();
// Media.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
// looked up once per reference instead of searched.
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in rev.media.iter().enumerate() {
rev_at.entry(m.id).or_insert(i);
}
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in doc.media.iter().enumerate() {
doc_at.entry(m.id).or_insert(i);
}
for id in crate::media_refs::slide_media(&sl) {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
Some(x) if x.data == m.data => {}
None => {
// Keeps the revision's id, so later ids must start past it.
doc_at.insert(id, doc.media.len());
doc.media.push(m.clone());
doc.next_id = doc.next_id.max(id.0);
}
Some(_) => {
let before = doc.media.len();
let new = doc
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
if doc.media.len() > before {
doc_at.entry(new).or_insert(before);
}
media_map.insert(id, new);
}
}
@@ -519,6 +580,39 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
let mut cur = defaults::new_presentation(None);
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
let mut rev = cur.clone();
let pic = |id: u32| Shape {
id: ShapeId(id),
name: "Box".into(),
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
let mut base = (*rev.slides[0]).clone();
base.shapes.clear();
for k in 0..3000 {
let mut s = base.clone();
s.id = SlideId(300_000 + k);
rev.slides.push(Arc::new(s));
}
let changes = super::diff(&cur, &rev, false);
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
}
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
/// transition sound. `tag` starts the bytes of each.
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
+44 -5
View File
@@ -1,14 +1,21 @@
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
//! connectors whenever the shapes they connect move, resize or rotate.
use deckcraft_geom::{Point, Xfrm};
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use crate::Selection;
use crate::cmd::xfrm_of;
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
sites_in(doc, sel, s, Affine::IDENTITY)
}
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
/// slide, or the child space of the groups around it) onto the slide.
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
return vec![];
}
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
if local.is_empty() {
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
}
let a = x.affine();
let a = to_abs * x.affine();
local.into_iter().map(|p| a * p).collect()
}
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
/// and its sites are where the group draws them.
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
if depth > 64 {
return None;
}
for s in shapes {
if s.id == id {
return Some((s, to_abs));
}
if let ShapeKind::Group { children, child } = &s.kind {
let x = xfrm_of(doc, sel, s);
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
return Some(f);
}
}
}
None
}
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
}
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
/// no such shape.
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
}
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
pub fn nearest_site(
doc: &Presentation,
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
let snapshot: Vec<Shape> = list.clone();
let view = doc.clone();
let site = |id: ShapeId, idx: u32| -> Option<Point> {
let s = snapshot.iter().find(|s| s.id == id)?;
let v = sites(&view, sel, s);
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
let v = sites_of(&view, sel, &snapshot, id)?;
v.get(idx as usize).or(v.first()).copied()
};
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
+26 -14
View File
@@ -85,14 +85,20 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
};
}
let resolved = resolve(path)?;
@@ -101,8 +107,8 @@ impl AutomationRoots {
}
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
}
}
}
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("automation path rejected: path is empty".into());
}
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
}
}
}
+11 -7
View File
@@ -1,6 +1,8 @@
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
//! draws in a stand-in; see `FontDb::document_face`).
use std::collections::HashSet;
use deckcraft_fonts::FontDb;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Bullet, TextBody};
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
/// font to load and are left for the round trip.
pub fn install_embedded_fonts(p: &Presentation) {
let db = FontDb::global();
// Faces can name one media item many times; each is copied and enumerated once.
let mut seen = HashSet::new();
for font in &p.embedded_fonts {
for id in font.faces().into_iter().flatten() {
if !seen.insert(id) {
continue;
}
let Some(item) = p.media(id) else { continue };
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
db.add_font(sfnt.to_vec());
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
/// and can't be installed, so they aren't reported.
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
let db = FontDb::global();
let mut out: Vec<String> = Vec::new();
for family in used_fonts(p) {
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
out.push(family);
}
}
// used_fonts is already free of case-insensitive duplicates.
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
out.sort_by_key(|f| f.to_lowercase());
out
}
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
/// The families the slides' text runs and bullets are drawn in.
pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut add = |f: String| {
let f = f.trim().to_string();
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
out.push(f);
}
};
+31 -2
View File
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path),
};
let target_str = target_path.to_str().unwrap_or(path);
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0;
let (tmp, mut file) = loop {
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
// A short fixed-size name: appending to the target's name could exceed the file name limit.
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = target_path.with_file_name(tmp_name);
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
crate::file_access::check_write(&tmp.to_string_lossy())?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -120,4 +123,30 @@ mod tests {
let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[test]
fn write_file_overwrites_file_with_name_near_the_length_limit() {
let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join(format!("{}.pptx", "a".repeat(250)));
std::fs::write(&file, b"old").unwrap();
let path_str = file.to_str().unwrap();
write_file(path_str, b"new").unwrap();
assert_eq!(read_file(path_str).unwrap(), b"new");
let _ = std::fs::remove_file(&file);
}
#[test]
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
assert!(res.unwrap_err().contains("outside the write root"));
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
let _ = std::fs::remove_dir_all(&base);
}
}
+41 -6
View File
@@ -454,17 +454,30 @@ pub struct MediaStatus {
}
/// Clipboard contents copied within the app.
/// The theme a copy drew with. The colours and the map stay apart, as in
/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and
/// a direct `dk1` keeps the theme's colour.
#[derive(Clone, Debug)]
pub struct SourceTheme {
/// The theme's own colours, no colour map applied.
pub colors: deckcraft_color::ColorScheme,
pub color_map: Vec<(String, String)>,
pub fonts: deckcraft_model::theme::FontScheme,
}
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
pub shapes: Vec<Shape>,
/// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as
/// regular shapes; a paste in Slide Master view takes these, which stay placeholders.
pub placeholders: Vec<Shape>,
pub slides: Vec<Slide>,
/// Media the copied shapes/slides reference.
pub media: Vec<deckcraft_model::MediaItem>,
pub text: Option<deckcraft_model::TextBody>,
pub plain: String,
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
/// Keep Source Formatting.
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
/// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
pub source_theme: Option<SourceTheme>,
/// How many times the shapes were pasted (each paste offsets further).
pub pastes: u32,
}
@@ -811,10 +824,32 @@ fn clamp_selection(st: &mut DocState) {
/// Default for [`History::byte_limit`].
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
use deckcraft_model::text::{Paragraph, Run};
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
}
/// A rough size of comments in memory, replies included.
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
}
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
/// all copied with the slide when an edit changes it.
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
text_cost(&s.notes)
+ comments_cost(&s.comments, 0)
+ s.name.len()
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
+ s.raw_ext.as_ref().map_or(0, String::len)
}
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
let text: usize =
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
let text: usize = sh.text.as_ref().map_or(0, text_cost);
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
}
@@ -832,7 +867,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
let mut n = 64 * doc.slides.len();
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
n += 512 + shapes_cost(&s.shapes);
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
}
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
+116 -16
View File
@@ -171,7 +171,9 @@ impl Dictionary {
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
["REP", n] => {
for _ in 0..n.parse::<usize>().unwrap_or(0) {
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
// The count is the file's claim: stop when the records run out.
let Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
if let [_, a, b, ..] = r.as_slice() {
d.rep.push((a.replace('_', " "), b.replace('_', " ")));
}
@@ -182,7 +184,8 @@ impl Dictionary {
let Some(flag) = one_flag(Some(flag), mode) else { continue };
let cross = cross.eq_ignore_ascii_case("y");
for _ in 0..n.parse::<usize>().unwrap_or(0) {
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
let Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue };
let (add, cont) = match add.split_once('/') {
Some((a, c)) => (a, parse_flags(c, mode)),
@@ -304,7 +307,12 @@ impl Dictionary {
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
/// end flags), each at least `COMPOUNDMIN` characters long.
fn compounded(&self, w: &[char], index: usize) -> bool {
fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool {
// `w` is always a tail of the checked word, so its length names it: a (tail, part index)
// that failed once fails again however the earlier parts were split.
if failed.contains(&(w.len(), index)) {
return false;
}
if self.compound.is_none() && self.compound_end.is_none() {
return false;
}
@@ -322,10 +330,11 @@ impl Dictionary {
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
return true;
}
if self.compounded(rest, index + 1) {
if self.compounded(rest, index + 1, failed) {
return true;
}
}
failed.insert((w.len(), index));
false
}
@@ -352,7 +361,7 @@ impl Dictionary {
return true;
}
}
self.compounded(&w, 0)
self.compounded(&w, 0, &mut HashSet::new())
}
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
@@ -576,6 +585,15 @@ fn cache() -> &'static Cache {
CACHE.get_or_init(Default::default)
}
/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call.
const MAX_CACHED_LANGS: usize = 256;
/// Parsed dictionaries by their affix file, so aliases of one language share it.
fn by_files() -> &'static Mutex<HashMap<PathBuf, Arc<Dictionary>>> {
static M: OnceLock<Mutex<HashMap<PathBuf, Arc<Dictionary>>>> = OnceLock::new();
M.get_or_init(Default::default)
}
/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process.
pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
let cache = cache();
@@ -586,10 +604,23 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
return d.clone();
}
let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| {
let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
Some(Arc::new(Dictionary::parse(&a, &d)))
// Tags that resolve to the same files share one parsed dictionary.
if let Ok(m) = by_files().lock()
&& let Some(dict) = m.get(&a)
{
return Some(dict.clone());
}
let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?);
let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d));
// A concurrent loader of the same files may have inserted first: share its copy.
if let Ok(mut m) = by_files().lock() {
return Some(m.entry(a).or_insert(dict).clone());
}
Some(dict)
});
if let Ok(mut c) = cache.lock() {
if let Ok(mut c) = cache.lock()
&& c.len() < MAX_CACHED_LANGS
{
c.insert(key, loaded.clone());
}
loaded
@@ -668,6 +699,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(());
}
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
@@ -700,15 +733,29 @@ pub struct Word {
pub repeated: Option<usize>,
}
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
/// value from a file is no language we have a dictionary for, and copying it into every word of
/// a paragraph would multiply it.
const MAX_LANG_LEN: usize = 64;
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
/// part by part.
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
let mut chars: Vec<(char, usize)> = vec![];
for (ri, r) in p.runs.iter().enumerate() {
chars.extend(r.content().chars().map(|c| (c, ri)));
}
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
let lang_of = |ri: usize| {
p.runs
.get(ri)
.and_then(|r| r.props.lang.as_deref())
.filter(|l| !l.is_empty())
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
.unwrap_or(default)
.to_string()
};
// Combining marks (decomposed accents) belong to the letter before them.
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
let mut out = vec![];
@@ -791,15 +838,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default))
.collect::<Vec<_>>()
.into_iter()
.fold(vec![], |mut v: Vec<Word>, w| {
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
// A repeated misspelt word is reported once, as repeated.
if let Some(i) = v.iter().position(|x| x.start == w.start) {
v[i] = w;
} else {
v.push(w);
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
Some(x) => *x = w,
None => {
at.insert(w.start, v.len());
v.push(w);
}
}
v
(v, at)
})
.0
}
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -880,6 +930,14 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
}
/// An affix header can claim billions of records; parsing stops where the file does.
#[test]
fn a_header_count_past_the_records_ends_with_them() {
let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n";
let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice());
assert!(d.check("cats"));
}
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
#[test]
@@ -914,6 +972,17 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
}
}
#[test]
fn overlapping_compound_roots_with_a_failing_ending_stay_cheap() {
// Roots a..aaaaaaaa overlap, so many splits of "aaa…ab" reach the same (tail, part) state; unmemoized that is millions of searches.
let d = Dictionary::parse(
b"COMPOUNDFLAG X\nCOMPOUNDMIN 1\n".as_slice(),
b"8\na/X\naa/X\naaa/X\naaaa/X\naaaaa/X\naaaaaa/X\naaaaaaa/X\naaaaaaaa/X\n".as_slice(),
);
assert!(!d.check(&format!("{}b", "a".repeat(99))));
assert!(d.check(&"a".repeat(20)));
}
#[test]
fn runs_give_words_their_language() {
use deckcraft_model::{Paragraph, Run, RunProps};
@@ -953,6 +1022,37 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
// 100,000 copies of one word under a 1 MB language value: every word after the first is
// a repeat, and no word carries a copy of the value.
let lang = "x".repeat(1 << 20);
let p =
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
let words = paragraph_words(&p, "en-US");
assert_eq!(words.len(), 100_000);
assert!(words.iter().all(|w| w.lang.is_empty()));
let bad = misspelt(&p, "en-US", &[]);
assert_eq!(bad.len(), 99_999);
assert!(bad.iter().all(|w| w.repeated.is_some()));
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
}
#[test]
fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
+696 -1
View File
@@ -214,6 +214,368 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
fn resolved_look(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let slide = st.current_slide().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap();
let sh = st.shape(id).unwrap();
let t = sh.text.as_ref().unwrap();
let b = resolve::body(&ctx, sh);
let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit);
for p in &t.paragraphs {
let pp = resolve::para(&ctx, sh, p);
out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet);
for r in &p.runs {
let rp = resolve::run(&ctx, sh, p, &r.props);
out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill);
}
}
out
}
/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its
/// layout and master gave it: the title's size and font, the body's bullets and indents. Before,
/// the copy fell back to the master's other-text style (18 pt, no bullets).
#[test]
fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for (k, id) in phs.iter().enumerate() {
s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap();
}
for id in phs {
let want = resolved_look(&s, id);
for cmd in ["paste", "duplicate"] {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
if cmd == "paste" {
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
} else {
s.execute("edit.duplicate", &json!({})).unwrap();
}
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0);
}
}
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the
/// body's bullets. `edit.duplicate` is not the only way to duplicate.
#[test]
fn a_placeholder_duplicated_by_dragging_keeps_its_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for id in phs {
s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap();
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
let st = s.doc().unwrap();
let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) };
s.pointer(alt(PointerKind::Down, cx, cy)).unwrap();
s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap();
s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id, "the drag made a copy");
assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder");
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0);
}
}
/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour
/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour
/// an override gave it.
#[test]
fn keep_source_formatting_uses_the_slides_colour_map() {
use deckcraft_model::resolve;
let first_run_colour = |s: &Session, id: ShapeId| {
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
let p = &sh.text.as_ref().unwrap().paragraphs[0];
resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props))
};
for on_layout in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let plain = first_run_colour(&s, title);
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
let doc = std::sync::Arc::make_mut(&mut st.doc);
if on_layout {
let layout = doc.slides[at].layout;
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map();
} else {
std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map();
}
let want = first_run_colour(&s, title);
assert_ne!(want, plain, "the override changes how the title draws");
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, title);
assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}");
}
}
/// A colour map that swaps text and background, as a dark slide or layout has.
fn swapped_colour_map() -> Option<Vec<(String, String)>> {
Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec())
}
/// A new rectangle in the edited tree, filled with theme colour `slot`.
fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId {
use deckcraft_model::{ColorRef, Fill};
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let id = ShapeId(u32::try_from(id).unwrap());
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot)));
Ok(())
})
.unwrap();
id
}
/// How shape `id`'s fill draws in the edited tree.
fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba {
use deckcraft_model::{Fill, resolve};
let st = s.doc().unwrap();
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
match resolve::fill(&ctx, st.shape(id).unwrap()).0 {
Some(Fill::Solid { color }) => ctx.color(&color, None),
other => panic!("not a solid fill: {other:?}"),
}
}
/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id.
fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
copy
}
/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot
/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour.
#[test]
fn keep_source_formatting_maps_only_the_alias_colours() {
use deckcraft_color::SchemeSlot::{self, *};
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1];
let mut drawn = vec![];
for slot in slots {
let id = rect_filled_with(&mut s, slot);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "{slot:?}");
drawn.push(want);
}
assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`");
}
/// In Slide Master view a copy draws through the edited master's map, not an override on the slide
/// that happens to be current.
#[test]
fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() {
use deckcraft_color::SchemeSlot::Tx1;
for master in [false, true] {
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap();
let id = rect_filled_with(&mut s, Tx1);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}");
}
}
/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where
/// it lands; only a paste on a slide turns it into a regular shape with the look it had.
#[test]
fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = resolved_look(&s, title);
let source = s.doc().unwrap().shape(title).unwrap().clone();
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
for paste in ["edit.paste", "edit.pasteSpecial"] {
s.execute(paste, &json!({"as": "keepSource"})).unwrap();
let st = s.doc().unwrap();
let copy = st.shape(st.selection.shapes[0]).unwrap();
assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder");
let list = copy.text.as_ref().map(|t| t.list_style.clone());
assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles");
}
s.execute("view.closeMaster", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want);
}
/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours.
fn resolved_box(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh))
}
/// Edit the title placeholders the current slide's title inherits from: its layout's and its
/// master's. Returns the slide title's id.
fn edit_title_parents(
s: &mut Session,
layout: impl FnOnce(&mut deckcraft_model::Shape),
master: impl FnOnce(&mut deckcraft_model::Shape),
) -> ShapeId {
let st = s.doc_mut().unwrap();
let slide = st.current_slide().unwrap().clone();
let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id;
let doc = std::sync::Arc::make_mut(&mut st.doc);
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap();
layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
title
}
/// A copied placeholder takes each of fill, line and effects from where the original does: a
/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the
/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide.
#[test]
fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle};
for style_on_slide in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let style = ShapeStyle {
line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), None),
};
let layout_style = (!style_on_slide).then(|| style.clone());
let title = edit_title_parents(
&mut s,
|l| l.style = layout_style,
|m| {
m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0));
m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() });
},
);
if style_on_slide {
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone());
Ok(())
})
.unwrap();
}
s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}");
}
}
/// The master's placeholder style never reaches the copy's text: the original's text ignores it
/// (only the shape's or the layout's font reference counts), so the copy must too.
#[test]
fn a_pasted_placeholder_ignores_the_masters_font_reference() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, ShapeStyle};
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = edit_title_parents(
&mut s,
|l| l.style = None,
|m| {
m.style = Some(ShapeStyle {
line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))),
})
},
);
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1);
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.duplicate", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want);
}
/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles).
#[test]
fn sample_placeholders_paste_with_their_look() {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let slides = s.doc().unwrap().doc.slides.len();
let mut checked = 0;
for i in 0..slides {
s.execute("slide.go", &json!({"index": i})).unwrap();
let phs: Vec<ShapeId> = s
.doc()
.unwrap()
.current_slide()
.unwrap()
.shapes
.iter()
.filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty()))
.map(|x| x.id)
.collect();
for id in phs {
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0);
checked += 1;
}
}
assert!(checked >= 3, "the sample has text placeholders ({checked})");
}
#[test]
fn pasted_shapes_get_the_next_automatic_name() {
let mut s = session();
@@ -1351,6 +1713,31 @@ fn tab_moves_between_cells_skipping_merged_ones() {
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
}
#[test]
fn format_paragraph_align_names_match_format_align() {
use deckcraft_model::text::Align;
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.align;
for (name, want) in [
("justLow", Align::JustLow),
("justifyLow", Align::JustLow),
("thaiDist", Align::ThaiDistributed),
("ctr", Align::Center),
("centre", Align::Center),
("r", Align::Right),
("just", Align::Justify),
("dist", Align::Distributed),
("distributed", Align::Distributed),
("left", Align::Left),
] {
s.execute("format.paragraph", &json!({"align": name})).unwrap();
assert_eq!(para(&s), Some(want), "{name}");
}
assert!(s.execute("format.paragraph", &json!({"align": "bogus"})).is_err());
}
#[test]
fn right_to_left_paragraphs() {
let mut s = session();
@@ -2410,6 +2797,23 @@ fn pdf_options_take_paper_and_orientation() {
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
}
/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page.
#[test]
fn pdf_options_reject_unsupported_per_page() {
for n in [1, 2, 3, 4, 6, 9] {
let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap();
assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n));
}
for n in [0, 5, 7, 8, 10] {
let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err();
assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}");
}
// Not a count at all: refused too, instead of falling back to 6.
for v in [json!(-1), json!(5.5), json!("6")] {
assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}");
}
}
#[test]
fn shape_line_sets_compound_and_arrow_sizes() {
// The Format Shape pane's Line page: compound type, arrowheads and their sizes.
@@ -3206,6 +3610,7 @@ fn numbering_takes_color_and_size() {
props.bullet_font_follow_text = Some(true);
props.bullet_color_follow_text = Some(true);
props.bullet_size_follow_text = Some(true);
props.bullet_size_points = Some(12.0);
}
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
let para = |s: &Session| {
@@ -3219,9 +3624,11 @@ fn numbering_takes_color_and_size() {
assert_eq!(pp.bullet_font_follow_text, Some(true));
assert_eq!(pp.bullet_color_follow_text, None);
assert_eq!(pp.bullet_size_follow_text, None);
assert_eq!(pp.bullet_size_points, None);
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
assert_eq!(para(&s).bullet_size, Some(4.0));
assert_eq!(para(&s).bullet_size_points, None);
assert_eq!(para(&s).bullet_font_follow_text, None);
}
@@ -3338,6 +3745,25 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
}
/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a
/// copy of those notes per step, and must not slip past the budget as if the slide were empty.
#[test]
fn undo_history_counts_slide_notes() {
let mut s = Session::with_new();
let notes = "n".repeat(1 << 20);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(&notes);
Ok(())
})
.unwrap();
s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() };
for i in 0..40 {
s.execute("slide.rename", &json!({"name": format!("v{i}"), "index": 0})).unwrap();
}
let n = s.doc().unwrap().history.undo.len();
assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget");
}
#[test]
fn picture_styles_replace_each_other_in_one_step() {
const PNG: &[u8] = &[
@@ -4301,6 +4727,9 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
for kind in ["linear", "radial", "rectangular", "path"] {
assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({
@@ -5023,6 +5452,38 @@ fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() {
assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]);
}
#[test]
fn a_long_ink_stroke_is_extended_in_place_and_capped() {
let mut s = session();
s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 });
let ev = |kind, x: f64| tools::PointerEvent { kind, x, y: 80.0, mods: Mods::default(), tol: 3.0, pressure: Some(0.5) };
s.pointer(ev(PointerKind::Down, 0.0)).unwrap();
let n = tools::MAX_GESTURE_POINTS + 5_000;
for i in 1..n {
s.pointer(ev(PointerKind::Drag, (i % 500) as f64)).unwrap();
}
assert_eq!(s.tool.ink_preview.len(), tools::MAX_GESTURE_POINTS);
s.pointer(ev(PointerKind::Up, 0.0)).unwrap();
let kept: usize = ink_pressures(&s).iter().map(Vec::len).sum();
assert!((2..=tools::MAX_GESTURE_POINTS).contains(&kept), "{kept}");
}
#[test]
fn double_clicking_a_capped_freeform_keeps_its_vertices() {
let mut s = session();
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None };
s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() });
s.pointer(ev(tools::PointerKind::Down, 100.0, 100.0)).unwrap();
// Densely clicked vertices, each within the double-click tolerance of the last.
for i in 0..tools::MAX_GESTURE_POINTS + 10 {
s.pointer(ev(tools::PointerKind::Down, 300.0 + i as f64 * 0.001, 100.0)).unwrap();
}
s.pointer(ev(tools::PointerKind::DoubleClick, 300.0, 100.0)).unwrap();
let st = s.doc().unwrap();
let deckcraft_model::Geom::Custom { paths } = &st.shapes().last().unwrap().geom else { panic!("no freeform") };
assert_eq!(paths[0].d.matches(" L ").count(), tools::MAX_GESTURE_POINTS - 1);
}
fn ink_pressures(s: &Session) -> Vec<Vec<f32>> {
let st = s.doc().unwrap();
let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else {
@@ -5342,7 +5803,8 @@ fn opening_a_file_caps_absurd_effect_sizes() {
e.soft_edge = Some(f64::NAN);
let sw = e.outer_shadow.as_mut().unwrap();
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
*e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
*e.reflection.as_mut().unwrap() =
Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() };
}
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
@@ -6458,3 +6920,236 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
}
/// A shape named many times in one edit is edited once and not copied per mention.
#[test]
fn repeated_target_ids_edit_the_shape_once() {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "x".repeat(100_000)})).unwrap();
let id = r["id"].as_u64().unwrap();
let edited = s.execute("shape.fill", &json!({"ids": vec![id; 50_000], "color": "#ff0000"})).unwrap();
assert_eq!(edited, json!(1));
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
assert_eq!(v["shape"]["fill"]["color"]["base"]["rgb"], json!({"a": 255, "b": 0, "g": 0, "r": 255}));
}
#[test]
fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() {
// 30,000 runs in 15,000 families, each written twice in different case.
let mut p = deckcraft_model::Presentation::default();
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
let mut body = deckcraft_model::text::TextBody::from_text("x");
let run = body.paragraphs[0].runs[0].clone();
body.paragraphs[0].runs = (0..30_000)
.map(|i| {
let mut r = run.clone();
let name = format!("Qqq Font {}", i / 2);
r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() });
r
})
.collect();
slide.shapes[0].text = Some(body);
let used = crate::fonts::used_fonts(&p);
assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000);
assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000);
}
#[test]
fn slide_master_view_rejects_invalid_indices() {
let mut s = Session::with_new();
for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] {
let r = s.execute("view.slideMaster", &p);
assert!(r.is_err(), "invalid index accepted for {p}: {r:?}");
}
assert!(s.execute("view.slideMaster", &json!({})).is_ok());
assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok());
}
#[test]
fn save_to_image_path_is_an_error_not_zip_bytes() {
let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] {
let mut s = Session::new();
sample::open_sample(&mut s).unwrap();
let path = dir.join(name).to_string_lossy().to_string();
let before = s.doc().unwrap().path.clone();
assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail");
assert!(!std::path::Path::new(&path).exists(), "{name} must not be written");
assert_eq!(s.doc().unwrap().path, before);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's
/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at
/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes,
/// and the sites of a grouped shape were computed in the group's child space.
#[test]
fn shape_connect_glues_to_a_shape_inside_a_group() {
use deckcraft_model::{ShapeId, ShapeKind};
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let glue = |s: &Session, id: u64| {
let st = s.doc().unwrap();
match st.shape(ShapeId(id as u32)).unwrap().kind {
ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)),
_ => (None, None),
}
};
let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6;
// A new connector into the group.
let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap();
let k = r["id"].as_u64().unwrap();
assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}");
let (p0, p1) = line_ends(&s, k);
assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}");
assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}");
s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap();
assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move");
let (_, p1) = line_ends(&s, k);
assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}");
// Re-gluing an existing line with `id`.
let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap();
assert_eq!(r["id"], l, "{r}");
assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}");
let (_, p1) = line_ends(&s, l);
assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}");
s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap();
assert_eq!(glue(&s, l), (Some(c), Some(b)));
let (_, p1) = line_ends(&s, l);
assert!(near(p1, 160.0, 325.0), "{p1:?}");
// shape.sites answers in slide space, as shape.connect uses them: A's right site, A moved
// with the group.
let v = s.execute("shape.sites", &json!({"id": a})).unwrap();
assert!(
v.as_array().unwrap().iter().any(|q| near(deckcraft_geom::Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap()), 160.0, 135.0)),
"{v}"
);
// A line inside a group is refused as `id`: its ends would be written in slide space into
// the group's space, and rerouting does not follow grouped connectors.
let m = s.execute("shape.insert", &json!({"preset": "line", "rect": [600, 300, 50, 0]})).unwrap()["id"].as_u64().unwrap();
let n = s.execute("shape.insert", &json!({"preset": "rect", "rect": [600, 450, 50, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("arrange.group", &json!({"ids": [m, n]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("shape.connect", &json!({"id": m, "from": c, "to": b})).unwrap_err().to_string();
assert!(e.contains("inside a group"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
// A grouped shape that is not a line is refused as not a line, not as a grouped line.
let e = s.execute("shape.connect", &json!({"id": n, "from": c, "to": b})).unwrap_err().to_string();
assert!(e.contains("not a line"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
}
#[test]
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
}
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
assert!(e.contains("SmartArt"), "{e}");
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
// Still a SmartArt graphic: smartArt commands keep working.
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
// Ordinary shapes still flip.
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
let id = b["id"].as_u64().unwrap();
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
}
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
#[test]
fn table_cell_commands_refuse_a_malformed_cells_list() {
let mut s = session();
let t = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
let rev = s.doc().unwrap().revision;
for cells in [
json!([[-1, 0]]),
json!([[0]]),
json!([[0, 0], "x"]),
json!([[0, 0, "x"]]),
json!([[0, 2]]),
json!([[0, u64::MAX]]),
json!("0,0"),
json!({"0": 0}),
] {
for (cmd, extra) in [
("table.cellFill", json!({"color": "#FF0000"})),
("table.cellMargins", json!({"left": 4})),
("table.cellAlign", json!({"align": "center"})),
("table.borders", json!({"which": "right"})),
] {
let mut p = extra.clone();
p["id"] = json!(t);
p["cells"] = cells.clone();
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains(cmd) && e.contains("cell"), "{cmd} {cells}: {e}");
}
}
assert_eq!(s.doc().unwrap().revision, rev, "nothing changed");
let many = json!(vec![[0, 0]; 100_001]);
assert!(s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": many})).unwrap_err().to_string().contains("at most"));
s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": [[1, 1]]})).unwrap();
assert!(table_of(&s, t).cell(1, 1).unwrap().fill.is_some() && table_of(&s, t).cell(0, 0).unwrap().fill.is_none());
}
/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer,
/// date and slide-number placeholders are not body lines.
#[test]
fn outline_export_skips_footer_date_and_slide_number() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
s.execute(
"design.headerFooter",
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
)
.unwrap();
let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.md");
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
// The RTF outline (Save As ▸ Outline/RTF) too.
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.rtf");
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap();
let rtf = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
}
#[test]
fn shape_adjust_and_quick_style_reject_invalid_index() {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
let before = s.doc().unwrap().shape(id).unwrap().clone();
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("shape.adjust", &json!({"index": bad, "value": 0.4})).is_err(), "adjust accepted {bad}");
assert!(s.execute("shape.quickStyle", &json!({"index": bad})).is_err(), "quickStyle accepted {bad}");
}
assert_eq!(s.doc().unwrap().shape(id).unwrap().geom, before.geom);
assert_eq!(s.doc().unwrap().shape(id).unwrap().style, before.style);
s.execute("shape.adjust", &json!({"value": 0.4})).unwrap();
}
+3
View File
@@ -17,6 +17,7 @@ mod geometry;
mod hit;
mod pointer;
pub(crate) use drag::MAX_GESTURE_POINTS;
pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -275,6 +276,8 @@ pub struct ToolState {
pub path_preview: Vec<Point>,
pub path_smooth: bool,
pub path_closing: bool,
/// Whether the last freeform press added a vertex (it adds none at the point cap).
pub press_added_vertex: bool,
/// The shape in Edit Points: its vertices are handles instead of the box's.
pub edit_points: Option<ShapeId>,
/// The vertex (path, index) whose control handles show in Edit Points.
+6 -2
View File
@@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool
impl Session {
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// The double-click's own click added a duplicate vertex.
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
// The double-click's own click added a duplicate vertex (none at the point cap, where
// the existing vertices are all kept).
while self.tool.press_added_vertex
&& points.len() > 2
&& points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol)
{
points.pop();
}
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
+28 -7
View File
@@ -12,9 +12,16 @@ struct MoveDrag {
enter_text: Option<(ShapeId, Point)>,
}
/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further.
pub(crate) const MAX_GESTURE_POINTS: usize = 100_000;
impl Session {
/// Update the active drag interaction using the pointer position and modifiers.
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if matches!(self.tool.drag, Some(Drag::Ink { .. })) {
self.extend_ink(p);
return Ok(Value::Null);
}
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
Drag::Guide { index } => {
@@ -181,7 +188,7 @@ impl Session {
}
Drag::Freeform { mode, mut points, pressed } => {
// Dragging scribbles (except for curves, which only take clicked points).
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
points.push(p);
}
let mut v = points.clone();
@@ -191,15 +198,24 @@ impl Session {
self.tool.path_preview = v;
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
}
Drag::Ink { mut points } => {
points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
// Handled above.
Drag::Ink { .. } => {}
}
Ok(Value::Null)
}
/// Ink grows by one sample per move: extend it (and its preview) in place rather than
/// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`].
fn extend_ink(&mut self, p: Point) {
let sample = self.ink_sample(p);
if let Some(Drag::Ink { points }) = self.tool.drag.as_mut()
&& points.len() < MAX_GESTURE_POINTS
{
points.push(sample);
self.tool.ink_preview.push(sample);
}
}
fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
let mut d = p - start;
@@ -236,7 +252,12 @@ impl Session {
}
}
// A connector dragged away from shapes that stay put comes unglued at those ends.
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
let mut moved: Vec<ShapeId> = vec![];
for (id, _) in &origs2 {
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
}
}
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
+1
View File
@@ -101,6 +101,7 @@ impl Session {
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
cmd::edit::detach_on_slide(doc, sel, &mut copies);
cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
c.xfrm = Some(b);
+4 -1
View File
@@ -228,7 +228,10 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
return self.finish_freeform(true);
}
points.push(p);
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
if self.tool.press_added_vertex {
points.push(p);
}
self.tool.path_preview = points.clone();
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
return Ok(Value::Null);
+31 -3
View File
@@ -169,8 +169,14 @@ impl FontDb {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
let f = if self.system_fallback_enabled() {
self.face(fam, style)
} else {
let Some(f) = self.find(fam, style) else { continue };
f
};
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
@@ -227,6 +233,9 @@ impl FontDb {
}
}
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -236,9 +245,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -246,7 +258,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
@@ -306,4 +317,21 @@ mod tests {
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
/// installed family: the result comes from what is already loaded.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
let before = loaded(&db);
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
assert!(!f.family.is_empty());
}
assert_eq!(loaded(&db), before, "no installed family was loaded");
}
}
+163 -22
View File
@@ -368,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
outlines: Mutex<OutlineCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -439,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -694,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
@@ -730,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(),
};
let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
if !seen.insert(style.to_ascii_lowercase()) {
continue;
}
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords));
}
if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new()));
} else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -905,7 +950,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -931,6 +976,17 @@ impl FontDb {
let _ = on;
}
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
pub(crate) fn system_fallback_enabled(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
}
#[cfg(target_arch = "wasm32")]
false
}
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
@@ -1215,9 +1271,8 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
@@ -1227,6 +1282,11 @@ impl FontDb {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
// A tag the font has no axis for changes nothing; keeping it would grow the list with
// every distinct tag in the style text.
if !axis_tags.contains(&tag) {
continue;
}
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
@@ -1238,7 +1298,7 @@ impl FontDb {
Some(f)
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
@@ -1424,7 +1484,8 @@ impl FontDb {
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let cataloged: Vec<String> =
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
@@ -1513,41 +1574,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
return p;
}
let mut pen = FlipPen(BezPath::new());
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
let p = Arc::new(pen.finish());
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
if self.room() {
self.path.move_to((x as f64, -y as f64));
}
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
if self.room() {
self.path.line_to((x as f64, -y as f64));
}
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
}
fn close(&mut self) {
self.0.close_path();
if self.room() {
self.path.close_path();
}
}
}
@@ -1575,6 +1667,55 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+8
View File
@@ -425,3 +425,11 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
assert!(fifo || n >= 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabling_system_fallback_gates_automatic_family_loads() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
db.set_system_fallback(false);
assert!(!db.system_fallback_enabled());
}
+9 -9
View File
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
let mut p = fixture(vec![7; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let bytes = save(&p).unwrap();
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
let duplicated = rewrite_manifest(&bytes, |manifest| {
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
media.push(media[0].clone());
});
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
}
#[test]
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
part.remove("nativeEntry");
part.insert("bytes".into(), json!(vec![9_u8; 600]));
});
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
}
#[test]
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
}
// A failed CRC may already have inflated bytes. Continuing with the same remaining
// budget would let each corrupt member consume that work budget again.
let result = load_within(&bytes, Budget { total: 10, items: 2 });
let result = load_within(&bytes, Budget::media(10, 2));
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
}
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
assert_eq!(replaced, 2);
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+66 -14
View File
@@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30;
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
const MAX_MANIFEST: u64 = 256 << 20;
/// What one load may inflate.
#[derive(Clone, Copy)]
struct Budget {
total: u64,
items: usize,
manifest: u64,
}
#[cfg(test)]
impl Budget {
/// A media budget with the default manifest limit.
fn media(total: u64, items: usize) -> Self {
Budget { total, items, manifest: MAX_MANIFEST }
}
}
#[derive(Debug, thiserror::Error)]
@@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
}
#[cfg(test)]
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
read_entry_max(z, name, MAX_ENTRY)
}
@@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
/// stopping at `max` if it inflates further than it declared.
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
read_entry_counted(z, name, max, &mut 0)
}
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
/// including those inflated before a decode or CRC error, so a member that fails late still
/// spends the shared budget it used.
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
let mut spent = 0;
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
*left = left.saturating_sub(spent);
out
}
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if f.size() > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
let mut f = f.take(max.saturating_add(1));
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
let read = f.read_to_end(&mut out);
*spent = spent.saturating_add(out.len() as u64);
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if out.len() as u64 > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
@@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
}
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
if z.index_for_name("presentation.json").is_none() {
return Err(FormatError::NotOurs("no presentation.json".into()));
}
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
let m = parse_manifest(&json)?;
if m.format != "deckcraft" {
return Err(FormatError::NotOurs(m.format));
@@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
}
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
Ok(data) => {
left -= data.len() as u64;
item.data = Arc::new(data);
}
// A failed read is charged for what it inflated, so later members can't reuse that budget.
match read_entry_charged(&mut z, &name, &mut left) {
Ok(data) => item.data = Arc::new(data),
Err(_) if linked_only => {}
Err(e) => damaged.push(e.to_string()),
}
@@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) {
}
p.fix_next_id();
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
// Indexed once: a per-slide scan of every layout is slides × layouts work.
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
let mut seen = std::collections::HashSet::new();
for i in 0..p.slides.len() {
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
if needs_layout || dup {
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
@@ -214,6 +247,11 @@ pub fn repair(p: &mut Presentation) {
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
p.notes_size = Presentation::default().notes_size;
}
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
p.notes_size = deckcraft_model::Size::new(
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
);
sane_numbers(p);
}
@@ -331,16 +369,20 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
let mut src = json.to_vec();
let mut last = match failure(&src) {
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
// patching (the copy is bounded by `MAX_MANIFEST`).
let mut last = match failure(json) {
Ok(m) => return Ok(m),
Err(f) => f,
};
if !is_null_number(&last.0) {
return Err(FormatError::Damaged(last.0));
}
let mut src = json.to_vec();
let mut budget = MAX_REPARSE_BYTES;
'fixes: for _ in 0..MAX_NULL_FIXES {
let (msg, pos) = &last;
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
if !number {
if !is_null_number(msg) {
break;
}
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
@@ -374,6 +416,11 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
Err(FormatError::Damaged(last.0))
}
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
fn is_null_number(msg: &str) -> bool {
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
}
/// Byte offset of a 1-based `line`:`column`.
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
let mut start = 0;
@@ -453,6 +500,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
r.blur = effect_size(r.blur, MAX_REFLECTION);
r.dist = effect_size(r.dist, MAX_REFLECTION);
if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) {
r.align = None;
}
}
/// Run properties a list style hands down to the text that inherits it.
@@ -644,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String {
out.push_str(&s.title().replace('\n', " "));
out.push('\n');
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
// The title is the slide's line; footer, date and slide-number placeholders are not
// body text (PowerPoint's outline view shows title and body placeholders only).
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
continue;
}
if let Some(t) = &sh.text {
+10 -2
View File
@@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
// As in the text outline: footer, date and slide-number placeholders are not body text.
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
continue;
}
let Some(t) = &sh.text else { continue };
@@ -299,7 +300,8 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
self.i += 1;
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
if self.st.skip {
return;
}
@@ -308,6 +310,7 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {}
}
}
@@ -419,6 +422,11 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
#[test]
fn multibyte_control_symbol_keeps_character_and_following_text() {
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
+167 -4
View File
@@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() {
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
}
#[test]
fn alternate_run_language_survives_two_native_saves_and_absence() {
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
let mut p = defaults::new_presentation(None);
Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody {
paragraphs: vec![Paragraph {
runs: vec![
Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
],
..Default::default()
}],
..Default::default()
});
let mut q = load(&save(&p).expect("save")).expect("load");
for _ in 0..2 {
let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[1].props.alt_lang, None);
q = load(&save(&q).expect("save again")).expect("load again");
}
}
#[test]
fn native_save_keeps_chart_relationships_and_payloads() {
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
@@ -48,6 +73,38 @@ fn native_save_keeps_chart_relationships_and_payloads() {
}
}
#[test]
fn native_table_direction_and_column_text_survive_two_saves() {
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use std::sync::Arc;
for rtl in [Some(true), Some(false), None] {
let mut p = defaults::new_presentation(None);
let mut table = Table::new(1, 2, 200.0, 30.0);
table.rtl = rtl;
table.rows[0].cells[0] = Cell::new("Left column");
table.rows[0].cells[1] = Cell::new("Right column");
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
let mut q = load(&save(&p).expect("save")).expect("load");
for _ in 0..2 {
let table = q.slides[0]
.shapes
.iter()
.find_map(|s| match &s.kind {
ShapeKind::Table(table) => Some(table),
_ => None,
})
.expect("table shape");
assert_eq!(table.rtl, rtl);
assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column");
assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column");
q = load(&save(&q).expect("save again")).expect("load again");
}
}
}
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
@@ -93,9 +150,60 @@ fn media_budget_is_cumulative() {
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
let bytes = save(&p).unwrap();
// Exactly at the limit opens; one byte under is refused, though each item fits.
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
}
#[test]
fn corrupt_media_is_charged_for_what_it_inflated() {
let mut p = Presentation::default();
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
let mut bytes = save(&p).unwrap();
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
bytes[at] ^= 0xff;
}
// Each member fits the budget on its own; the second must not reuse what the first spent.
let result = load_within(&bytes, Budget::media(20, 2));
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
// With room for both, the file opens and reports both.
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
assert_eq!(damaged.len(), 2, "{damaged:?}");
}
#[test]
fn oversized_manifest_is_refused_before_inflating() {
let bytes = save(&Presentation::default()).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let size = z.by_name("presentation.json").unwrap().size();
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
#[test]
fn many_slides_on_missing_layouts_are_repaired() {
let mut p = Presentation::default();
let template = p.slides[0].clone();
let good = template.layout;
p.slides = (0..2000u32)
.map(|i| {
let mut s = (*template).clone();
s.id = deckcraft_model::SlideId(10_000 + i);
if i % 2 == 1 {
s.layout = deckcraft_model::LayoutId(900_000 + i);
}
Arc::new(s)
})
.collect();
p.next_id = 20_000;
repair(&mut p);
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
assert_eq!(p.slides[0].layout, good);
}
#[test]
@@ -124,7 +232,7 @@ fn repeated_media_ids_are_read_once() {
}
let bytes = out.finish().unwrap().into_inner();
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
assert_eq!(q.media.len(), 1);
assert_eq!(q.media[0].data.len(), 1000);
// And the deck saves again.
@@ -208,6 +316,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() {
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
}
#[test]
fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() {
use deckcraft_model::style::Reflection;
use deckcraft_model::{Effects, Shape};
let mut p = Presentation::default();
let reflection = Reflection {
blur: 1.0,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.75,
dist: 2.0,
st_pos: Some(20_000),
fade_dir: Some(0),
sx: Some(75_000),
sy: Some(-50_000),
kx: Some(600_000),
align: Some("bl".into()),
rotate_with_shape: Some(false),
..Default::default()
};
let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
let once = load(&save(&p).expect("save")).expect("load");
let twice = load(&save(&once).expect("save again")).expect("load again");
assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection));
let absent = Reflection {
blur: 1.0,
start_alpha: 1.0,
end_alpha: 0.0,
end_pos: 1.0,
dist: 0.0,
st_pos: None,
dir: None,
fade_dir: None,
sx: None,
sy: None,
kx: None,
ky: None,
align: None,
rotate_with_shape: None,
};
let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
let reopened = load(&save(&p).expect("save absent values")).expect("load absent values");
assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent));
}
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
#[test]
@@ -295,6 +451,13 @@ fn degenerate_slide_sizes_are_repaired() {
}
}
#[test]
fn repair_bounds_the_notes_page_size() {
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
repair(&mut p);
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
}
/// A file whose slide size is null, a string or missing a side (written by hand or by
/// another tool; NaN is also saved as null) opens with the default size rather than failing
/// as damaged.
+31 -2
View File
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
out.extend([p1, p2, p3]);
}
/// A piece length ordered totally, so it can sit in a heap.
#[derive(PartialEq)]
struct Share(f64);
impl Eq for Share {}
impl PartialOrd for Share {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Share {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&o.0)
}
}
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
/// splits go to the longest pieces, so a long side gets more points than a short one.
fn refine(c: &Contour, n: usize) -> Contour {
@@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour {
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
let mut parts = vec![1usize; curves.len()];
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
// this O(n log n) instead of rescanning every curve for each split.
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
for _ in 0..n - have {
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
if let Some(p) = parts.get_mut(best) {
*p += 1;
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
}
}
let mut out = Vec::with_capacity(1 + 3 * n);
@@ -420,6 +439,16 @@ mod tests {
assert_eq!(contours(&to_path(&cs)), cs);
}
#[test]
fn refine_many_curves_finishes_at_the_asked_count() {
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
for i in 1..=20_000 {
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
}
let r = refine(&c, 40_000);
assert_eq!(r.curves(), 40_000);
}
#[test]
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
+19 -9
View File
@@ -928,11 +928,25 @@ impl Warp {
k_fill = 1.0;
}
zoom = zoom.max(k_fill);
// Per band, measured once: its widest line and its first and last lines' baselines.
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
for (l, &b) in lines.iter().zip(&band_of) {
let w = l.extent.1 - l.extent.0;
if let Some(slot) = stats.get_mut(b) {
match slot {
Some((widest, _, last)) => {
*widest = widest.max(w);
*last = l.ref_y;
}
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
}
}
}
let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0);
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
let band = stats.get(b).copied().flatten();
match self.bands.get(b) {
Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
@@ -941,16 +955,12 @@ impl Warp {
}
Some(Band::Follow { path, anchor }) => {
let len = path.length();
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
let widest = band.map_or(0.0, |s| s.0);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor {
LineAnchor::Last => members.last().map(|l| l.ref_y),
LineAnchor::First => members.first().map(|l| l.ref_y),
LineAnchor::Middle => match (members.first(), members.last()) {
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
LineAnchor::Last => band.map(|s| s.2),
LineAnchor::First => band.map(|s| s.1),
LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
}
.unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length.
+17
View File
@@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() {
assert!(more > adjust_defaults("textInflate")[0], "{more}");
assert!((more - 20_000.0).abs() < 1.0, "{more}");
}
#[test]
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
let w = warp("textArchUp", &[], rect()).unwrap();
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
let lines: Vec<WarpLine> = (0..50_000)
.map(|i| {
let y = i as f64 * 0.01;
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
})
.collect();
let m = w.mapper(&lines, inner);
// The last line sits on the arc, as with two lines.
let last = lines.len() - 1;
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
}
+1 -1
View File
@@ -48,7 +48,7 @@ impl SliceDecoder<'_> {
let intra_slice = self.sh.slice_type.is_intra();
loop {
ensure!(addr < total, "macroblock address {addr} out of range");
self.start_mb(addr);
self.start_mb(addr)?;
let skip = !intra_slice && self.decode_skip_flag(&mut c);
if skip {
self.decode_skip()?;
+43 -10
View File
@@ -15,7 +15,7 @@ use crate::slicedec::{PicState, SliceDecoder};
use crate::transform::LevelScale;
use crate::{ColorInfo, Picture};
use deckcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape_rbsp};
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
/// One slice of a picture job.
@@ -90,6 +90,10 @@ pub struct Decoder {
ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>,
/// Pictures leaving the DPB, converted once their decoding job has finished.
out_queue: VecDeque<Output>,
/// Queued entries per frame id and their summed bytes, kept in step with `out_queue` so the
/// budget check does not rescan it.
queued_ids: HashMap<u32, u64>,
queued_bytes: u64,
shared: Arc<Shared>,
#[cfg(feature = "threads")]
pool: Option<rayon::ThreadPool>,
@@ -175,6 +179,8 @@ impl Decoder {
active_sps: None,
ls_cache: Vec::new(),
out_queue: VecDeque::new(),
queued_ids: HashMap::new(),
queued_bytes: 0,
shared: Arc::new(Shared::default()),
#[cfg(feature = "threads")]
pool,
@@ -283,7 +289,7 @@ impl Decoder {
if !o.frame.is_published(o.frame.mb_h() - 1) && self.out_queue.len() <= max_queue {
break;
}
let Some(o) = self.out_queue.pop_front() else { break };
let Some(o) = self.pop_out() else { break };
pics.push(make_picture(&o));
}
Ok(pics)
@@ -295,7 +301,7 @@ impl Decoder {
let mut outs = Vec::new();
self.dpb.flush(&mut outs);
self.emit(outs);
let pics = self.out_queue.drain(..).map(|o| make_picture(&o)).collect();
let pics = std::iter::from_fn(|| self.pop_out()).map(|o| make_picture(&o)).collect();
while let Some(f) = self.in_flight.pop_front() {
f.wait_complete();
}
@@ -451,13 +457,24 @@ impl Decoder {
chroma_planes: if chroma == 0 { 0 } else { 2 },
bytes_per_sample: 1,
};
let pictures = (sps.max_dpb_frames() + 1 + self.max_in_flight) as u64;
ensure!(
shape.fits(pictures, self.memory_budget),
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
sps.width(),
sps.height()
);
// Pictures already held count at their own size, not the incoming SPS's: after a
// resolution change, larger pictures of the old size can still be in the DPB or waiting
// for output (one decode call may carry many access units). A buffer shared with a DPB
// entry is counted once.
let pic = shape.bytes().unwrap_or(u64::MAX);
let held = |frames: &mut dyn Iterator<Item = &Arc<Frame>>| frames.fold(0u64, |n, f| n.saturating_add(f.bytes()));
// Queued bytes minus the queue entries that share a DPB frame (each distinct DPB frame once).
let mut ids: Vec<(u32, u64)> = self.dpb.entries.iter().map(|e| (e.frame.id, e.frame.bytes())).collect();
ids.sort_unstable();
ids.dedup();
let shared = ids.iter().fold(0u64, |n, (id, b)| n.saturating_add(self.queued_ids.get(id).copied().unwrap_or(0).saturating_mul(*b)));
let queued = self.queued_bytes.saturating_sub(shared);
let in_dpb = held(&mut self.dpb.entries.iter().map(|e| &e.frame));
let dpb = sps.max_dpb_frames().max(self.dpb.entries.len()) as u64;
// The DPB (the larger of its new slots and what it holds now), the picture being decoded,
// and what is in flight or queued.
let bytes = pic.saturating_mul(dpb).max(in_dpb).saturating_add(pic).saturating_add(pic.saturating_mul(self.max_in_flight as u64).max(queued));
ensure!(bytes <= self.memory_budget, "{}x{} pictures with {bytes} bytes in the decoder exceed the memory budget", sps.width(), sps.height());
Ok(())
}
@@ -562,8 +579,24 @@ impl Decoder {
}
fn emit(&mut self, outs: Vec<Output>) {
for o in &outs {
*self.queued_ids.entry(o.frame.id).or_insert(0) += 1;
self.queued_bytes = self.queued_bytes.saturating_add(o.frame.bytes());
}
self.out_queue.extend(outs);
}
fn pop_out(&mut self) -> Option<Output> {
let o = self.out_queue.pop_front()?;
if let Some(n) = self.queued_ids.get_mut(&o.frame.id) {
*n -= 1;
if *n == 0 {
self.queued_ids.remove(&o.frame.id);
}
}
self.queued_bytes = self.queued_bytes.saturating_sub(o.frame.bytes());
Some(o)
}
}
/// Decode all slices of one picture and publish its rows.
+53 -10
View File
@@ -139,8 +139,9 @@ impl Dpb {
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
// Only the last `max_num_ref_frames` "non-existing" frames can survive the sliding window, and
// inserting more than that evicts every earlier short-term reference. Skip straight to that tail
// (keeping the POC state walk for every missing frame_num) so a hostile gap of up to 65535
// frames costs at most `max_num_ref_frames` frame allocations instead of one per missing frame.
// (advancing the POC state across the skipped run in constant time) so a hostile gap of up to
// 65535 frames costs at most `max_num_ref_frames` frame allocations and POC updates instead of
// one per missing frame.
let gap = frame_num.wrapping_add(max_frame_num).wrapping_sub(unused) % max_frame_num;
let keep = u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX);
let skip = gap.saturating_sub(keep);
@@ -149,10 +150,17 @@ impl Dpb {
e.mark = RefMark::Unused;
}
self.prune();
for _ in 0..skip {
poc_state(unused);
unused = (unused + 1) % max_frame_num;
// The POC state only advances FrameNumOffset where frame_num decreases, so walking the
// skipped run is the same as visiting its first value, the wrap (if the run crosses
// it) and its last value: constant work however long the gap.
let last = (unused + (skip - 1) % max_frame_num) % max_frame_num;
poc_state(unused);
if last < unused {
poc_state(max_frame_num - 1);
poc_state(0);
}
poc_state(last);
unused = (last + 1) % max_frame_num;
}
let mut guard = 0;
while unused != frame_num && guard < max_frame_num {
@@ -438,14 +446,15 @@ impl Dpb {
}
}
} else {
while self.entries.len() >= self.capacity {
// Evict until there is room (frame_num gap concealment can leave the DPB over-full), so
// the store never holds more pictures than the memory budget was checked for.
while self.entries.len() >= self.capacity.max(1) {
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;
}
}
}
@@ -546,6 +555,37 @@ mod tests {
(made, walk, left)
}
/// (FrameNumOffset, prev frame_num) after `walk`, by the rule of `PocState::update_gap_frame`.
fn poc_after(walk: &[u32], prev: u32, max: u32) -> (u64, u32) {
walk.iter().fold((0, prev), |(off, prev), &f| (if prev > f { off + u64::from(max) } else { off }, f))
}
#[test]
fn gap_walk_matches_every_frame_num_for_any_skip() {
for (prev, cur) in [(1, 9), (10, 3), (14, 0), (15, 14), (0, 15), (7, 6)] {
for refs in 1..4 {
let mut dpb = Dpb::new();
let (_, walk, _) = fill(&mut dpb, prev, cur, 16, refs);
let every: Vec<u32> = (1..16).map(|i| (prev + i) % 16).take_while(|&f| f != cur).collect();
assert_eq!(poc_after(&walk, prev, 16), poc_after(&every, prev, 16), "{prev} -> {cur}, {refs} refs");
assert_eq!(walk.last(), every.last(), "{prev} -> {cur}, {refs} refs");
}
}
}
#[test]
fn reference_store_evicts_down_to_capacity() {
let mut dpb = Dpb::new();
dpb.capacity = 2;
// Gap concealment can leave more non-output references than the capacity.
for i in 0..5 {
short(&mut dpb, i, i);
}
let mut out = Vec::new();
assert!(!dpb.bump_for_space(RefMark::Short, 0, &mut out));
assert!(dpb.entries.len() < 2, "{} pictures left before storing into a 2-picture DPB", dpb.entries.len());
}
#[test]
fn huge_gap_makes_only_the_frames_that_stay_referenced() {
let mut dpb = Dpb::new();
@@ -553,8 +593,9 @@ mod tests {
short(&mut dpb, 2, 1);
let (made, walk, left) = fill(&mut dpb, 1, 65535, 65536, 4);
assert_eq!(made, 4);
// The POC state still walks every missing frame_num, in order.
assert_eq!(walk.len(), 65533);
// The POC state ends where walking every missing frame_num would leave it, in a few steps.
assert_eq!(poc_after(&walk, 1, 65536), poc_after(&(2..65535).collect::<Vec<_>>(), 1, 65536));
assert!(walk.len() <= 8, "{} POC updates", walk.len());
assert_eq!(walk.first(), Some(&2));
assert_eq!(walk.last(), Some(&65534));
assert_eq!(left, vec![(65531, true), (65532, true), (65533, true), (65534, true)]);
@@ -566,7 +607,9 @@ mod tests {
short(&mut dpb, 1, 65000);
let (made, walk, left) = fill(&mut dpb, 65000, 3, 65536, 2);
assert_eq!(made, 2);
assert_eq!(walk.len(), 538);
let every: Vec<u32> = (65001..65536).chain(0..3).collect();
assert_eq!(poc_after(&walk, 65000, 65536), poc_after(&every, 65000, 65536));
assert!(walk.len() <= 6, "{} POC updates", walk.len());
assert_eq!(walk.first(), Some(&65001));
assert_eq!(walk.last(), Some(&2));
assert_eq!(left, vec![(1, true), (2, true)]);
+8
View File
@@ -160,6 +160,14 @@ impl Frame {
self.rows.len()
}
/// Sample bytes this picture holds (luma and both chroma planes), as the decoder's memory
/// budget counts a picture.
pub fn bytes(&self) -> u64 {
(self.width as u64)
.saturating_mul(self.height as u64)
.saturating_add((self.cwidth as u64).saturating_mul(self.cheight as u64).saturating_mul(2))
}
/// Publish MB row `r` (ignored if already published).
pub fn publish(&self, r: usize, row: FrameRow) {
let _ = self.rows[r].set(row);
+8 -4
View File
@@ -346,8 +346,11 @@ impl<'a> SliceDecoder<'a> {
// Neighbour helpers
// ------------------------------------------------------------------------------------------
/// Set up the current macroblock address and neighbour availability.
pub fn start_mb(&mut self, addr: usize) {
/// Set up the current macroblock address and neighbour availability. A macroblock an earlier
/// slice of this picture already decoded is refused: overlapping slices would otherwise redo
/// the picture's reconstruction once per slice.
pub fn start_mb(&mut self, addr: usize) -> Result<()> {
ensure!(self.pic.mbs[addr].slice_num == u32::MAX, "macroblock {addr} overlaps an earlier slice");
self.mb_addr = addr;
self.mb_x = addr % self.mb_w;
self.mb_y = addr / self.mb_w;
@@ -364,6 +367,7 @@ impl<'a> SliceDecoder<'a> {
];
self.mv_done = 0;
self.pic.mbs[addr] = MbState { slice_num: sn, ..MbState::default() };
Ok(())
}
#[inline]
@@ -1467,7 +1471,7 @@ impl<'a> SliceDecoder<'a> {
let run = r.read_ue()? as usize;
ensure!(addr + run <= total, "mb_skip_run out of range");
for _ in 0..run {
self.start_mb(addr);
self.start_mb(addr)?;
self.decode_skip()?;
self.pic.mb_done(addr);
addr += 1;
@@ -1477,7 +1481,7 @@ impl<'a> SliceDecoder<'a> {
}
ensure!(addr < total, "macroblock address {addr} out of range");
}
self.start_mb(addr);
self.start_mb(addr)?;
self.decode_mb_cavlc(r)?;
self.pic.mb_done(addr);
addr += 1;
+64 -1
View File
@@ -113,6 +113,7 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
poc_lsb: Some(0),
poc_delta: None,
wt: None,
first_mb: 0,
},
false,
);
@@ -153,6 +154,8 @@ struct Pic {
/// Explicit weights (luma, cb, cr) as (weight, offset) for refIdx 0 of list 0 and list 1;
/// log2 denominators are 5 (luma) and 3 (chroma).
wt: Option<[[(i32, i32); 3]; 2]>,
/// first_mb_in_slice (`mbs` then lists the slice's macroblocks from there).
first_mb: u32,
}
impl Pic {
@@ -165,7 +168,7 @@ impl Pic {
}
fn slice_header(w: &mut BitWriter, p: &Pic, cabac: bool) {
w.write_ue(0); // first_mb
w.write_ue(p.first_mb);
w.write_ue(if p.b {
6
} else if p.is_p() {
@@ -660,3 +663,63 @@ fn frame_num_gap_is_filled_when_the_sps_allows_gaps() {
assert_eq!(out.len(), 2);
assert_eq!(dec.stats().frame_num_gaps, 1);
}
/// An IDR picture followed by `pictures` reference P pictures of P_Skip macroblocks.
fn skip_stream(pictures: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
let mut seed = 11u32;
let a: Vec<Option<Vec<u8>>> = (0..NMB).map(|_| Some(random_mb(&mut seed))).collect();
let mut head = sps(66, 2, false, PocType::T2);
head.extend(pps(false, false));
head.extend(slice_nal(&Pic { idr: true, frame_num: 0, ref_idc: 3, mbs: a, ..Default::default() }, false));
let ps = (1..=pictures).map(|k| slice_nal(&Pic { frame_num: k % 16, ref_idc: 2, mbs: vec![None; NMB], ..Default::default() }, false)).collect();
(head, ps)
}
#[test]
fn overlapping_slices_are_not_reconstructed_twice() {
let (head, ps) = skip_stream(1);
let mut once = Decoder::with_threads(1);
let mut out = once.decode(&[head.clone(), ps[0].clone()].concat(), 0).unwrap();
out.extend(once.flush());
assert!(once.take_error().is_none());
assert_eq!(out.len(), 2);
// A second slice of the same picture starting at macroblock 1 covers those macroblocks again.
let again = slice_nal(&Pic { frame_num: 1, ref_idc: 2, mbs: vec![None; NMB - 1], first_mb: 1, ..Default::default() }, false);
let mut twice = Decoder::with_threads(1);
let result = twice.decode(&[head, ps[0].clone(), again].concat(), 0);
twice.flush();
let err = result.err().or_else(|| twice.take_error()).expect("overlapping slice refused");
assert!(err.to_string().contains("overlaps"), "{err}");
}
#[test]
fn output_queue_counts_against_the_memory_budget() {
let (head, ps) = skip_stream(40);
let sps_nal = sps(66, 2, false, PocType::T2);
let parsed = crate::params::Sps::parse(&deckcraft_bitstream::unescape_rbsp(&sps_nal[5..])).unwrap();
let one = (NMB * 256 + 2 * NMB * 64) as u64;
let budget = (parsed.max_dpb_frames() + 1 + 3) as u64 * one;
// One access unit per call: output drains as it goes, and the stream fits.
let mut dec = Decoder::with_threads(1);
dec.set_memory_budget(budget);
let mut out = dec.decode(&head, 0).unwrap();
for (i, p) in ps.iter().enumerate() {
out.extend(dec.decode(p, i as i64 + 1).unwrap());
}
out.extend(dec.flush());
assert!(dec.take_error().is_none());
assert_eq!(out.len(), 41);
// Every access unit in one call: the pictures held for output stop fitting the budget.
let mut dec = Decoder::with_threads(1);
dec.set_memory_budget(budget);
let err = dec.decode(&[head, ps.concat()].concat(), 0).expect_err("output queue is over budget");
assert!(err.to_string().contains("memory budget"), "{err}");
}
/// A held picture is charged at its own size (luma and both 4:2:0 chroma planes), the same
/// count the budget uses for the incoming SPS's pictures.
#[test]
fn a_held_frame_is_charged_at_its_own_size() {
let f = crate::picture::Frame::new(0, 0, 2, 3);
assert_eq!(f.bytes(), (2 * 3 * 256 + 2 * 2 * 3 * 64) as u64);
}
+28 -2
View File
@@ -404,10 +404,36 @@ impl Decoder {
chroma_planes: if chroma == 0 { 0 } else { 2 },
bytes_per_sample: 2,
};
let frame_bytes = |f: &crate::picture::Frame| {
(f.width as u64)
.saturating_mul(f.height as u64)
.saturating_add((f.cwidth as u64).saturating_mul(f.cheight as u64).saturating_mul(2))
.saturating_mul(2)
};
// Output frames waiting to be returned stay allocated after they leave the DPB, at their
// own size: a resolution change in this call can leave pictures of the previous SPS.
let queued: u64 = self
.out_queue
.iter()
.filter(|o| !self.dpb.entries.iter().any(|e| Arc::ptr_eq(&e.frame, &o.frame)) && !self.in_flight.iter().any(|f| Arc::ptr_eq(f, &o.frame)))
.map(|o| frame_bytes(&o.frame))
.fold(0, u64::saturating_add);
// Frames the DPB holds now count at their own size too (an SPS change flushes them into the
// queue before the new pictures are made): the DPB part is the larger of its new slots and that.
let held: u64 = self.dpb.entries.iter().map(|e| frame_bytes(&e.frame)).fold(0, u64::saturating_add);
// A queued frame still being decoded is one of the in-flight pictures already reserved, so the
// queue and the in-flight reservation overlap: charge the larger, not both.
let pictures = u64::from(sps.max_dec_pic_buffering) + 1 + self.max_in_flight as u64;
let slots = shape.store_bytes(u64::from(sps.max_dec_pic_buffering));
let flight = shape.store_bytes(self.max_in_flight as u64);
let total = shape
.store_bytes(1)
.zip(slots)
.zip(flight)
.map(|((one, slots), flight)| slots.max(held).saturating_add(one).saturating_add(flight).saturating_add(queued));
ensure!(
shape.fits(pictures, self.memory_budget),
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
total.is_some_and(|b| b <= self.memory_budget),
"{}x{} pictures with {pictures} in the decoder and {queued} bytes queued exceed the memory budget",
sps.width,
sps.height
);
+3 -1
View File
@@ -326,7 +326,9 @@ impl SliceHeader {
/// entry_point_offset list (tiles / wavefront substreams).
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
self.entry_points.clear();
// A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
// segment's header, and clearing would keep that copy's allocation for every segment.
self.entry_points = Vec::new();
if pps.tiles_enabled || pps.entropy_coding_sync {
let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "deckcraft-isobmff"
description = "Clean-room ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
craft-media = { workspace = true, features = ["isobmff"] }
-6
View File
@@ -1,6 +0,0 @@
//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of [`craft_media::isobmff`]
//! from craft-libs. Supported boxes and limitations are in the craft-media README.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub use craft_media::isobmff::*;
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "deckcraft-matroska"
description = "Clean-room Matroska (MKV) and WebM demuxer and muxer"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
craft-media = { workspace = true, features = ["matroska"] }
-6
View File
@@ -1,6 +0,0 @@
//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from
//! craft-libs. Supported elements and limitations are in the craft-media README.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub use craft_media::matroska::*;
+27 -1
View File
@@ -16,6 +16,12 @@ const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
/// much of the document (an inspection holds every note), so a batch holds at most this much.
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Keep request frames in step with the connected backend's reply limit.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
@@ -93,8 +99,28 @@ impl Server {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else if batch.len() > MAX_BATCH_ITEMS {
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
} else {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
let mut used = 0usize;
let mut replies: Vec<Value> = Vec::new();
for m in batch {
if used > MAX_BATCH_BYTES {
// Not run: answer each remaining request with a short error.
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
}
continue;
}
if let Some(r) = self.handle(m) {
used = used.saturating_add(r.to_string().len());
replies.push(if used > MAX_BATCH_BYTES {
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
} else {
r
});
}
}
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
+29 -10
View File
@@ -357,21 +357,40 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
}
"batch" => {
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if commands.len() > MAX_BATCH_ITEMS {
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
}
let mut out = vec![];
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let mut used = 0usize;
for c in commands {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
Err(e) => {
out.push(json!({"command": id, "ok": false, "error": e}));
if !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
};
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
// Failures count too: with keepGoing, a remote backend's large errors add up.
used = used.saturating_add(json!(entry.to_string()).to_string().len());
if used > MAX_BATCH_BYTES {
let shown: String = id.chars().take(128).collect();
out.push(json!({"command": shown, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
out.push(entry);
if failed && !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
ToolResult::json(&Value::Array(out))
// The reply is pretty-printed, which is longer than the compact entries counted above.
let reply = ToolResult::json(&Value::Array(out));
let text = reply.content[0]["text"].as_str().unwrap_or_default();
if json!(text).to_string().len() > MAX_BATCH_BYTES {
return ToolResult::error("the batch's results passed 64 MiB; nothing is returned");
}
reply
}
"new_presentation" => wrap(exec(b, "file.new", a)),
"open_presentation" => wrap(exec(b, "file.open", a)),
+70
View File
@@ -0,0 +1,70 @@
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
use deckcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
fn server_with_notes(bytes: usize) -> Server {
let mut server = Server::new(Box::new(Headless::with_document()));
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
assert_ne!(reply["result"]["isError"], true, "{reply}");
server
}
#[test]
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
let mut server = server_with_notes(1 << 20);
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
let too_many: Vec<Value> = (0..1001).map(ping).collect();
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
assert_eq!(reply["error"]["code"], -32600, "{reply}");
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
let line = Value::Array((0..100).map(inspect).collect()).to_string();
let reply = server.handle_line(&line).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
assert_eq!(replies.len(), 100, "every request is answered");
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
}
#[test]
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
let mut server = server_with_notes(1 << 20);
let call = |server: &mut Server, commands: Vec<Value>| {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
};
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
assert!(err && text.contains("at most 1000"), "{text}");
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
assert!(text.len() < (64 << 20) + (4 << 20));
}
#[test]
fn the_batch_tool_charges_the_escaped_reply() {
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
let mut server = Server::new(Box::new(Headless::with_document()));
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
server.handle_line(&notes.to_string()).unwrap();
let commands = vec![json!({"command": "slide.inspect"}); 30];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
#[test]
fn the_batch_tool_charges_failures_too() {
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
let mut server = Server::new(Box::new(Headless::with_document()));
let id = "x".repeat(1 << 20);
let commands = vec![json!({"command": id}); 70];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
+2
View File
@@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod batch_budget;
+3 -4
View File
@@ -9,10 +9,9 @@ description = "DeckCraft media: probing, audio decoding (MP3, AAC/M4A, WAV, AIFF
workspace = true
[dependencies]
deckcraft-isobmff = { workspace = true }
deckcraft-matroska = { workspace = true }
deckcraft-ogg = { workspace = true }
deckcraft-opus = { workspace = true }
# Containers (MP4/MOV, Matroska/WebM, Ogg) and the Opus decoder, shared with the other Craft apps.
craft-media = { workspace = true, features = ["isobmff", "matroska", "ogg"] }
craft-audio = { workspace = true }
deckcraft-h264 = { workspace = true }
deckcraft-hevc = { workspace = true }
deckcraft-vp9 = { workspace = true }
+12 -9
View File
@@ -81,7 +81,9 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
Some(r) => r,
None => contained(|| decode_symphonia(bytes, "mkv")),
},
Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())),
Container::Mp4 | Container::Unknown | Container::Mp3 if !crate::mp4guard::probe_fits(bytes) => {
Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()))
}
c => contained(|| decode_symphonia(bytes, c.name())),
}
}
@@ -89,15 +91,15 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
/// Ogg Opus with our decoder; `None` when the file holds no Opus stream.
fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let src: &[u8] = bytes;
let file = deckcraft_ogg::open(src).ok()?;
let s = file.stream_of(deckcraft_ogg::Codec::Opus)?;
let file = craft_media::ogg::open(src).ok()?;
let s = file.stream_of(craft_media::ogg::Codec::Opus)?;
let stream = file.streams.get(s)?;
Some((|| {
let head = stream.headers.first().ok_or(MediaError::Corrupt("Opus header missing".into()))?;
let mut dec = deckcraft_opus::Decoder::new(head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut dec = craft_audio::opus::Decoder::new(head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
dec.set_trim_pre_skip(true);
let channels = dec.channels() as u16;
let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let timing = craft_media::ogg::OpusTiming::of(stream, dec.pre_skip() as u32);
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 {
@@ -113,7 +115,8 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
}
}
// End trimming (RFC 7845 §4.5).
let total = timing.total.max(0) as usize * channels.max(1) as usize;
// the timing saturates on a hostile granule, so this product must too
let total = usize::try_from(timing.total).unwrap_or(0).saturating_mul(channels.max(1) as usize);
if total > 0 && total < out.len() {
out.truncate(total);
}
@@ -124,13 +127,13 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
/// WebM / Matroska Opus with our decoder; `None` when there is no Opus track.
fn decode_mkv_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let src: &[u8] = bytes;
let file = deckcraft_matroska::open(src).ok()?;
let file = craft_media::matroska::open(src).ok()?;
let (ti, head) = file.tracks.iter().enumerate().find_map(|(i, t)| match &t.codec {
deckcraft_matroska::Codec::Opus { head } => Some((i, head.clone())),
craft_media::matroska::Codec::Opus { head } => Some((i, head.clone())),
_ => None,
})?;
Some((|| {
let mut dec = deckcraft_opus::Decoder::new(&head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut dec = craft_audio::opus::Decoder::new(&head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
dec.set_trim_pre_skip(true);
let channels = dec.channels() as u16;
let mut out = Vec::new();
+49
View File
@@ -12,6 +12,29 @@ pub(crate) fn tables_fit(b: &[u8]) -> bool {
boxes_fit(b, b.len() as u64, 0)
}
/// Bytes symphonia's probe scans for a format marker before giving up.
const PROBE_SEARCH_LIMIT: usize = 1024 * 1024;
/// Whether symphonia can be given `b`, whatever the sniffer made of it: its probe takes the first
/// `ftyp` within its search window as the start of an MP4 (the size field 4 bytes before it), so
/// the table check must start where that parser will, not at offset 0.
pub(crate) fn probe_fits(b: &[u8]) -> bool {
if crate::Container::sniff(b) == crate::Container::Mp4 && !tables_fit(b) {
return false;
}
// The probe reads leading ID3v2 tags as metadata and searches again after the last one.
let mut rest = b;
while rest.starts_with(b"ID3") {
let size = rest.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
rest = rest.get(10 + size..).unwrap_or(&[]);
}
let head = &rest[..rest.len().min(PROBE_SEARCH_LIMIT + 16)];
match head.windows(4).position(|w| w == b"ftyp") {
Some(p) => tables_fit(&rest[p.saturating_sub(4)..]),
None => true,
}
}
fn be32(b: &[u8], at: usize) -> Option<u64> {
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64)
}
@@ -74,3 +97,29 @@ fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool {
};
be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry)
}
#[cfg(test)]
mod tests {
use super::*;
/// An MP4 whose `stsc` count asks for far more entries than the file holds.
fn mp4_with_huge_stsc() -> Vec<u8> {
let stsc = [&24u32.to_be_bytes()[..], b"stsc", &[0, 0, 0, 0], &u32::MAX.to_be_bytes(), &[0; 8]].concat();
let stbl = [&(stsc.len() as u32 + 8).to_be_bytes()[..], b"stbl", &stsc].concat();
let moov = [&(stbl.len() as u32 + 8).to_be_bytes()[..], b"moov", &stbl].concat();
[&16u32.to_be_bytes()[..], b"ftyp", b"isom", &[0; 4], &moov].concat()
}
#[test]
fn a_prefixed_mp4_is_checked_where_symphonia_starts_it() {
let mp4 = mp4_with_huge_stsc();
assert!(!tables_fit(&mp4));
assert!(!probe_fits(&mp4));
let prefixed = [&b"junk before the file "[..], &mp4].concat();
assert_eq!(crate::Container::sniff(&prefixed), crate::Container::Unknown);
assert!(tables_fit(&prefixed), "offset 0 sees no MP4 boxes");
assert!(!probe_fits(&prefixed));
let far = [&vec![0u8; PROBE_SEARCH_LIMIT + 16][..], &mp4].concat();
assert!(probe_fits(&far), "past the probe window symphonia never reads it as an MP4");
}
}
+10 -9
View File
@@ -71,7 +71,8 @@ pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
};
if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) {
// Plain audio files (and anything our demuxers didn't describe): symphonia's probe.
if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) {
// Symphonia opens an MP4 wherever its probe finds an `ftyp` (after leading ID3v2 tags too); other known containers (a WAV's audio bytes can spell an `ftyp`) skip the check.
if matches!(c, Container::Mp4 | Container::Unknown | Container::Mp3) && !crate::mp4guard::probe_fits(bytes) {
return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()));
}
let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name()))
@@ -105,9 +106,9 @@ fn codec_label(short: &str) -> String {
}
fn probe_mp4(bytes: &Bytes) -> Result<MediaInfo> {
use deckcraft_isobmff::{CodecConfig, TrackKind};
use craft_media::isobmff::{CodecConfig, TrackKind};
let src: &[u8] = bytes;
let f = deckcraft_isobmff::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let f = craft_media::isobmff::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut info = MediaInfo { container: if f.is_quicktime { "mov" } else { "mp4" }, ..Default::default() };
let mut dur = if f.timescale > 0 { f.duration as f64 / f.timescale as f64 } else { 0.0 };
if let Some(ti) = f.track_of_kind(TrackKind::Video) {
@@ -145,9 +146,9 @@ fn probe_mp4(bytes: &Bytes) -> Result<MediaInfo> {
}
fn probe_mkv(bytes: &Bytes) -> Result<MediaInfo> {
use deckcraft_matroska::{Codec, TrackKind};
use craft_media::matroska::{Codec, TrackKind};
let src: &[u8] = bytes;
let f = deckcraft_matroska::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let f = craft_media::matroska::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut info = MediaInfo { container: if f.is_webm() { "webm" } else { "matroska" }, ..Default::default() };
let dur = f.duration_ns().map(|n| n as f64 / 1e9).unwrap_or(0.0);
info.duration_ms = ms(dur);
@@ -178,11 +179,11 @@ fn probe_mkv(bytes: &Bytes) -> Result<MediaInfo> {
fn probe_ogg(bytes: &Bytes) -> Option<MediaInfo> {
let src: &[u8] = bytes;
let f = deckcraft_ogg::open(src).ok()?;
let s = f.stream_of(deckcraft_ogg::Codec::Opus)?;
let f = craft_media::ogg::open(src).ok()?;
let s = f.stream_of(craft_media::ogg::Codec::Opus)?;
let st = f.streams.get(s)?;
let head = deckcraft_opus::OpusHead::parse(st.headers.first()?).ok()?;
let timing = deckcraft_ogg::OpusTiming::of(st, head.pre_skip as u32).ok()?;
let head = craft_audio::opus::OpusHead::parse(st.headers.first()?).ok()?;
let timing = craft_media::ogg::OpusTiming::of(st, head.pre_skip as u32);
Some(MediaInfo {
container: "ogg",
duration_ms: ms(timing.total as f64 / 48_000.0),
+9 -9
View File
@@ -3,8 +3,8 @@
use std::collections::VecDeque;
use deckcraft_isobmff::CodecConfig;
use deckcraft_matroska::Codec as MkvCodec;
use craft_media::isobmff::CodecConfig;
use craft_media::matroska::Codec as MkvCodec;
use crate::yuv::{self, Matrix, Planes};
use crate::{Bytes, Container, MediaError, Result};
@@ -257,8 +257,8 @@ struct Track {
}
enum Demux {
Mp4(Box<deckcraft_isobmff::Mp4File>, usize),
Mkv(Box<deckcraft_matroska::MkvFile>, usize),
Mp4(Box<craft_media::isobmff::Mp4File>, usize),
Mkv(Box<craft_media::matroska::MkvFile>, usize),
}
impl Demux {
@@ -273,8 +273,8 @@ impl Demux {
fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
match Container::sniff(bytes) {
Container::Mp4 => {
let f = deckcraft_isobmff::open(bytes).map_err(dec_err)?;
let ti = f.track_of_kind(deckcraft_isobmff::TrackKind::Video).ok_or(MediaError::Missing("video track"))?;
let f = craft_media::isobmff::open(bytes).map_err(dec_err)?;
let ti = f.track_of_kind(craft_media::isobmff::TrackKind::Video).ok_or(MediaError::Missing("video track"))?;
let t = &f.tracks[ti];
let entry = t.entries.first().ok_or(MediaError::Missing("video track"))?;
let codec: Box<dyn Codec> = match &entry.codec {
@@ -290,11 +290,11 @@ fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
Ok((Demux::Mp4(Box::new(f), ti), track, codec))
}
Container::Matroska => {
let f = deckcraft_matroska::open(bytes).map_err(dec_err)?;
let f = craft_media::matroska::open(bytes).map_err(dec_err)?;
let ti = f
.tracks
.iter()
.position(|t| t.kind == deckcraft_matroska::TrackKind::Video && !t.samples.is_empty())
.position(|t| t.kind == craft_media::matroska::TrackKind::Video && !t.samples.is_empty())
.ok_or(MediaError::Missing("video track"))?;
let t = &f.tracks[ti];
let codec: Box<dyn Codec> = match &t.codec {
@@ -303,7 +303,7 @@ fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
MkvCodec::Vp9 { .. } => Box::new(Vp9 { dec: deckcraft_vp9::Decoder::new() }),
MkvCodec::Av1 { av1c } => Box::new(Av1 {
dec: deckcraft_av1::Decoder::new(),
config_obus: deckcraft_isobmff::Av1Config::parse(av1c).map(|c| c.config_obus).unwrap_or_default(),
config_obus: craft_media::isobmff::Av1Config::parse(av1c).map(|c| c.config_obus).unwrap_or_default(),
primed: false,
}),
other => return Err(MediaError::Unsupported(format!("{} video", mkv_codec_name(other)))),
+46
View File
@@ -166,6 +166,52 @@ fn ogg_opus() {
check_tone("opus", &fixture("tone.opus"), 48_000, 1);
}
/// Ogg Opus timing saturates on a hostile granule position instead of failing, so the end-trim
/// arithmetic on its total must not overflow either: a three-channel file that ends with a page
/// carrying the largest granule still probes and decodes.
#[test]
fn ogg_opus_with_a_huge_granule_does_not_overflow() {
fn seal(mut page: Vec<u8>) -> Vec<u8> {
page[22..26].fill(0);
let crc = craft_media::ogg::crc32(&page);
page[22..26].copy_from_slice(&crc.to_le_bytes());
page
}
let f = fixture("tone.opus");
let mut pages: Vec<Vec<u8>> = Vec::new();
let mut at = 0;
while at + 27 <= f.len() && &f[at..at + 4] == b"OggS" {
let nseg = f[at + 26] as usize;
let end = at + 27 + nseg + f[at + 27..at + 27 + nseg].iter().map(|&b| b as usize).sum::<usize>();
pages.push(f[at..end].to_vec());
at = end;
}
assert_eq!(at, f.len(), "the fixture is whole pages");
// an OpusHead for three channels (mapping family 1), each playing the one mono stream
let head = pages[0][28..].to_vec();
let mut first = pages[0][..26].to_vec();
first.extend_from_slice(&[1, 24]);
first.extend_from_slice(&head[..9]);
first.push(3);
first.extend_from_slice(&head[10..18]);
first.extend_from_slice(&[1, 1, 0, 0, 0, 0]);
pages[0] = seal(first);
// the last page once more, as the next page of the stream, with the largest granule
let last = pages.len() - 1;
let mut extra = pages[last].clone();
pages[last][5] &= !0x04;
pages[last] = seal(pages[last].clone());
extra[6..14].copy_from_slice(&i64::MAX.to_le_bytes());
let seq = u32::from_le_bytes([extra[18], extra[19], extra[20], extra[21]]) + 1;
extra[18..22].copy_from_slice(&seq.to_le_bytes());
pages.push(seal(extra));
let b: Bytes = Arc::new(pages.concat());
assert!(probe(&b).is_ok_and(|i| i.duration_ms > 1 << 40), "the granule is taken at its word");
let p = audio::decode(&b).expect("decode");
assert_eq!(p.channels, 3);
assert!(!p.samples.is_empty());
}
#[test]
fn wma_is_probed_but_not_decoded() {
let b = fixture("tone.wma");
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "deckcraft-metafile"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
-59
View File
@@ -1,59 +0,0 @@
//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so
//! a truncated or lying size field stops the parse instead of panicking.
#[derive(Clone, Copy)]
pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]);
impl<'a> Bytes<'a> {
pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> {
self.0.get(off..off.checked_add(len)?)
}
pub fn tail(&self, off: usize) -> Option<Bytes<'a>> {
self.0.get(off..).map(Bytes)
}
fn arr<const N: usize>(&self, off: usize) -> Option<[u8; N]> {
self.slice(off, N)?.try_into().ok()
}
pub fn u16(&self, off: usize) -> Option<u16> {
Some(u16::from_le_bytes(self.arr(off)?))
}
pub fn i16(&self, off: usize) -> Option<i16> {
Some(i16::from_le_bytes(self.arr(off)?))
}
pub fn u32(&self, off: usize) -> Option<u32> {
Some(u32::from_le_bytes(self.arr(off)?))
}
pub fn i32(&self, off: usize) -> Option<i32> {
Some(i32::from_le_bytes(self.arr(off)?))
}
pub fn f32(&self, off: usize) -> Option<f32> {
Some(f32::from_le_bytes(self.arr(off)?))
}
/// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`.
pub fn points(&self, off: usize, n: usize, wide: bool) -> Option<Vec<(f64, f64)>> {
let sz = if wide { 8 } else { 4 };
let raw = self.slice(off, n.checked_mul(sz)?)?;
let mut out = Vec::with_capacity(n);
for c in raw.chunks_exact(sz) {
let p = if wide {
let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?);
let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?);
(f64::from(x), f64::from(y))
} else {
let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?);
let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?);
(f64::from(x), f64::from(y))
};
out.push(p);
}
Some(out)
}
}
File diff suppressed because it is too large Load Diff
-193
View File
@@ -1,193 +0,0 @@
//! Clipping regions for EMF and WMF (MS-EMF 2.3.12, MS-WMF 2.3.5).
//!
//! A region is a set of axis-aligned rectangles in device units: `None` is the default clip (the
//! whole surface), `Some([])` draws nothing, and `Some(rects)` is their union. That is how GDI
//! stores a region (`RGNDATA` is a list of rectangles). Counts are capped so a hostile file cannot
//! grow the set without bound; past the cap, extra rectangles are dropped, which can only hide
//! drawing.
/// Rectangles kept from one region. Past this, further rectangles are dropped.
pub(crate) const MAX_CLIP_RECTS: usize = 64;
pub(crate) const RGN_AND: u32 = 1;
pub(crate) const RGN_OR: u32 = 2;
pub(crate) const RGN_XOR: u32 = 3;
pub(crate) const RGN_DIFF: u32 = 4;
pub(crate) const RGN_COPY: u32 = 5;
/// A rectangle `[x0, y0, x1, y1]` with a positive area, or none.
pub(crate) fn normalize(rect: [f64; 4]) -> Option<[f64; 4]> {
let [l, t, r, b] = rect;
if ![l, t, r, b].iter().all(|v| v.is_finite()) {
return None;
}
let (x0, x1) = (l.min(r), l.max(r));
let (y0, y1) = (t.min(b), t.max(b));
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6).then_some([x0, y0, x1, y1])
}
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1]));
let (x1, y1) = (a[2].min(b[2]), a[3].min(b[3]));
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6 && [x0, y0, x1, y1].iter().all(|v| v.is_finite())).then_some([x0, y0, x1, y1])
}
/// `a` with `b` removed, as up to four rectangles.
fn subtract_one(a: [f64; 4], b: [f64; 4]) -> Vec<[f64; 4]> {
let Some(i) = intersection(a, b) else { return vec![a] };
let mut out = Vec::new();
if i[1] > a[1]
&& let Some(r) = normalize([a[0], a[1], a[2], i[1]])
{
out.push(r);
}
if i[3] < a[3]
&& let Some(r) = normalize([a[0], i[3], a[2], a[3]])
{
out.push(r);
}
if i[0] > a[0]
&& let Some(r) = normalize([a[0], i[1], i[0], i[3]])
{
out.push(r);
}
if i[2] < a[2]
&& let Some(r) = normalize([i[2], i[1], a[2], i[3]])
{
out.push(r);
}
out
}
fn cap(mut rects: Vec<[f64; 4]>) -> Vec<[f64; 4]> {
rects.truncate(MAX_CLIP_RECTS);
rects
}
fn subtract_all(subject: Vec<[f64; 4]>, cuts: &[[f64; 4]]) -> Vec<[f64; 4]> {
let mut cur = subject;
for cut in cuts {
let mut next = Vec::new();
for s in cur {
for piece in subtract_one(s, *cut) {
if next.len() >= MAX_CLIP_RECTS {
return next;
}
next.push(piece);
}
}
cur = next;
}
cur
}
/// Intersects the current region with one rectangle.
pub(crate) fn intersect(current: Option<Vec<[f64; 4]>>, rect: [f64; 4]) -> Option<Vec<[f64; 4]>> {
let Some(rect) = normalize(rect) else { return Some(Vec::new()) };
Some(match current {
None => vec![rect],
Some(rects) => cap(rects.iter().filter_map(|r| intersection(*r, rect)).collect()),
})
}
/// Removes one rectangle. `surface` is the device bounds, used when the clip is still the default
/// (the whole surface) so the remainder can be named.
pub(crate) fn exclude(current: Option<Vec<[f64; 4]>>, rect: [f64; 4], surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
let Some(rect) = normalize(rect) else { return current };
match current {
Some(rects) => Some(subtract_all(rects, &[rect])),
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &[rect])),
}
}
/// Combines `incoming` with the current region in a region mode (`RGN_*`). An unknown mode leaves
/// the region as it was. `RGN_COPY` replaces it. An `RGN_OR` or `RGN_XOR` against the default clip
/// stays the default clip: the whole surface already covers the incoming rectangles.
pub(crate) fn combine(current: Option<Vec<[f64; 4]>>, incoming: Vec<[f64; 4]>, mode: u32, surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
let incoming: Vec<[f64; 4]> = cap(incoming.iter().copied().filter_map(normalize).collect());
match mode {
RGN_COPY => Some(incoming),
RGN_AND => match current {
None => Some(incoming),
Some(rects) => {
let mut out = Vec::new();
for a in &rects {
for b in &incoming {
if out.len() >= MAX_CLIP_RECTS {
return Some(out);
}
if let Some(r) = intersection(*a, *b) {
out.push(r);
}
}
}
Some(out)
}
},
RGN_OR => match current {
None => None,
Some(mut rects) => {
rects.extend(incoming);
Some(cap(rects))
}
},
RGN_DIFF => match current {
Some(rects) => Some(subtract_all(rects, &incoming)),
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &incoming)),
},
RGN_XOR => {
let base = current?;
let mut out = subtract_all(base.clone(), &incoming);
let rest = subtract_all(incoming, &base);
for r in rest {
if out.len() >= MAX_CLIP_RECTS {
break;
}
out.push(r);
}
Some(out)
}
_ => current,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intersect_shrinks_to_the_overlap() {
let r = intersect(None, [0.0, 0.0, 100.0, 50.0]);
let r = intersect(r, [40.0, 10.0, 80.0, 40.0]);
assert_eq!(r, Some(vec![[40.0, 10.0, 80.0, 40.0]]));
assert_eq!(intersect(r, [0.0, 0.0, 10.0, 10.0]), Some(vec![]));
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 20.0]]), [40.0, 0.0, 80.0, 20.0]), Some(vec![]));
}
#[test]
fn modes_copy_and_or_diff_and_xor() {
let a = Some(vec![[0.0, 0.0, 100.0, 40.0]]);
assert_eq!(combine(a.clone(), vec![[10.0, 10.0, 30.0, 20.0]], RGN_COPY, None), Some(vec![[10.0, 10.0, 30.0, 20.0]]));
let and = combine(a.clone(), vec![[50.0, 0.0, 150.0, 40.0]], RGN_AND, None);
assert_eq!(and, Some(vec![[50.0, 0.0, 100.0, 40.0]]));
let or = combine(a.clone(), vec![[200.0, 0.0, 210.0, 10.0]], RGN_OR, None).unwrap();
assert_eq!(or.len(), 2);
let diff = combine(a.clone(), vec![[20.0, 0.0, 40.0, 40.0]], RGN_DIFF, None).unwrap();
assert_eq!(diff, vec![[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 100.0, 40.0]]);
let xor = combine(a, vec![[80.0, 0.0, 120.0, 40.0]], RGN_XOR, None).unwrap();
assert!(xor.contains(&[0.0, 0.0, 80.0, 40.0]));
assert!(xor.contains(&[100.0, 0.0, 120.0, 40.0]));
}
#[test]
fn excluding_from_the_default_clip_uses_the_surface() {
let r = exclude(None, [10.0, 0.0, 20.0, 40.0], Some([0.0, 0.0, 40.0, 40.0])).unwrap();
assert_eq!(r, vec![[0.0, 0.0, 10.0, 40.0], [20.0, 0.0, 40.0, 40.0]]);
assert_eq!(exclude(None, [10.0, 0.0, 20.0, 40.0], None), None);
}
#[test]
fn a_zero_area_rectangle_clears_an_intersection() {
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 10.0]]), [5.0, 5.0, 5.0, 8.0]), Some(vec![]));
}
}
-339
View File
@@ -1,339 +0,0 @@
//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and
//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside
//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman
//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a
//! non-rectangular polygon) and text gets the polygon's bounding rectangle.
use crate::canvas::Raw;
use crate::dib::Rgba;
use crate::geom::{Op, P};
/// Line segments per Bézier when a crossing shape is flattened.
const CURVE_STEPS: usize = 16;
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
let mut b: Option<[f64; 4]> = None;
let mut add = |p: P| {
let v = b.get_or_insert([p.0, p.1, p.0, p.1]);
*v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)];
};
for op in ops {
match *op {
Op::M(p) | Op::L(p) => add(p),
Op::C(a, c, d) => {
add(a);
add(c);
add(d);
}
Op::Z => {}
}
}
b
}
pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
// An empty intersection stays empty (zero size) so it clips everything away.
if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r }
}
/// A rectangle `[l, t, r, b]` as a polygon.
pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec<P> {
vec![(l, t), (r, t), (r, b), (l, b)]
}
/// The bounding box of a point list.
pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> {
let (&f, rest) = pts.split_first()?;
Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]))
}
fn cross(a: P, b: P, p: P) -> f64 {
(b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)
}
/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or
/// degenerate); duplicate and collinear points are dropped.
pub(crate) fn convex(pts: &[P]) -> Option<Vec<P>> {
let mut v: Vec<P> = Vec::with_capacity(pts.len());
for &p in pts {
if !(p.0.is_finite() && p.1.is_finite()) {
return None;
}
if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) {
v.push(p);
}
}
while v.len() > 1 && v.first() == v.last() {
v.pop();
}
let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum();
if v.len() < 3 || area.abs() < 1e-12 {
return None;
}
if area < 0.0 {
v.reverse();
}
let n = v.len();
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]);
if c < -1e-9 * (1.0 + area.abs()) {
return None;
}
if c.abs() > 1e-12 {
out.push(v[i]);
}
}
(out.len() >= 3).then_some(out)
}
fn inside_poly(clip: &[P], p: P) -> bool {
(0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9)
}
/// Whether the clip polygon is an axis-aligned rectangle.
fn is_rect(clip: &[P]) -> bool {
pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3])))
}
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
return Vec::new();
}
let mut poly = poly.to_vec();
for i in 0..clip.len() {
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
return Vec::new();
}
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
let keep = |p: P| cross(a, b, p) >= 0.0;
let cut = |p: P, q: P| {
let (cp, cq) = (cross(a, b, p), cross(a, b, q));
let t = cp / (cp - cq);
(p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t)
};
let mut next = Vec::with_capacity(poly.len() + 4);
for j in 0..poly.len() {
let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]);
match (keep(p), keep(q)) {
(true, true) => next.push(q),
(true, false) => next.push(cut(p, q)),
(false, true) => {
next.push(cut(p, q));
next.push(q);
}
(false, false) => {}
}
}
poly = next;
if poly.is_empty() {
break;
}
}
poly
}
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1]
}
fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool {
rect_poly(b).into_iter().all(|p| inside_poly(clip, p))
}
/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes
/// back as two items. An empty polygon clips everything away.
pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() };
match raw {
Raw::Path { ops, fill, stroke, even_odd, region } => {
let Some(b) = ops_bbox(&ops) else { return Vec::new() };
if bbox_inside(b, clip) {
return vec![Raw::Path { ops, fill, stroke, even_odd, region }];
}
if disjoint(b, cb) {
return Vec::new();
}
let subs = flatten(&ops);
let mut out = Vec::new();
if fill.is_some() {
let f = clip_fill(&subs, clip);
if !f.is_empty() {
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
}
}
if stroke.is_some() {
let s = clip_stroke(&subs, clip);
if !s.is_empty() {
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
}
}
out
}
Raw::Pattern { ops, even_odd, tile, region } => {
let Some(b) = ops_bbox(&ops) else { return Vec::new() };
if bbox_inside(b, clip) {
return vec![Raw::Pattern { ops, even_odd, tile, region }];
}
if disjoint(b, cb) {
return Vec::new();
}
let f = clip_fill(&flatten(&ops), clip);
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
}
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
Raw::Text { text: mut t, region } => {
// Text clips to a rectangle: the one around the polygon.
let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)]));
t.clip = Some(cur.map(|v| v as f32));
vec![Raw::Text { text: t, region }]
}
}
}
/// Subpaths as point lists with their closed flag.
pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
let mut subs: Vec<(Vec<P>, bool)> = Vec::new();
for op in ops {
match *op {
Op::M(p) => subs.push((vec![p], false)),
Op::L(p) => match subs.last_mut() {
Some(s) => s.0.push(p),
None => subs.push((vec![p], false)),
},
Op::C(a, b, d) => {
let Some(s) = subs.last_mut() else { continue };
let Some(&p0) = s.0.last() else { continue };
for i in 1..=CURVE_STEPS {
let t = i as f64 / CURVE_STEPS as f64;
let u = 1.0 - t;
let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1));
}
}
Op::Z => {
if let Some(s) = subs.last_mut() {
s.1 = true;
// Drawing after a close starts from the subpath's first point.
let first = s.0.first().copied();
if let Some(f) = first {
subs.push((vec![f], false));
}
}
}
}
}
subs.retain(|s| s.0.len() > 1);
subs
}
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
let mut ops = Vec::new();
for (pts, _) in subs {
let poly = clip_polygon(pts, clip);
if poly.len() >= 3 {
ops.push(Op::M(poly[0]));
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
ops.push(Op::Z);
}
}
ops
}
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
let mut ops = Vec::new();
for (pts, closed) in subs {
let mut pts = pts.clone();
if *closed && let Some(&f) = pts.first() {
pts.push(f);
}
let mut last: Option<P> = None;
for w in pts.windows(2) {
let Some((a, b)) = segment(w[0], w[1], clip) else {
last = None;
continue;
};
if last != Some(a) {
ops.push(Op::M(a));
}
ops.push(Op::L(b));
last = Some(b);
}
}
ops
}
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`.
fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
let d = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for i in 0..clip.len() {
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
let f0 = cross(e0, e1, a);
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
if df == 0.0 {
if f0 < 0.0 {
return None;
}
} else {
let t = -f0 / df;
if df > 0.0 {
t0 = t0.max(t);
} else {
t1 = t1.min(t);
}
}
}
(t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1)))
}
/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip
/// polygon's box; pixels outside a non-rectangular polygon turn transparent.
fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> {
let [x, y, w, h] = rect;
let b = [x, y, x + w, y + h];
if bbox_inside(b, clip) {
return Some((rect, img));
}
let c = pts_bbox(clip)?;
if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 {
return None;
}
let i = intersect(b, c);
let (kx, ky) = (img.w as f64 / w, img.h as f64 / h);
let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w);
let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w);
let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h);
let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h);
if x1 <= x0 || y1 <= y0 {
return None;
}
let mask = !is_rect(clip);
let total_pixels = (x1 - x0).saturating_mul(y1 - y0);
if mask && total_pixels.saturating_mul(clip.len()) > 5_000_000 {
return None;
}
let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4);
for row in y0..y1 {
// Rows are top-down unless the image says otherwise.
let r = if img.bottom_up { img.h - 1 - row } else { row };
let s = (r * img.w + x0) * 4;
let start = px.len();
px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?);
if mask {
let cy = y + (row as f64 + 0.5) / ky;
for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() {
let cx = x + ((x0 + k) as f64 + 0.5) / kx;
if !inside_poly(clip, (cx, cy)) {
p[3] = 0;
}
}
}
}
let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky];
Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px }))
}
-259
View File
@@ -1,259 +0,0 @@
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
use crate::{MAX_PIXELS, bytes::Bytes};
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
/// Most palette entries a DIB can carry (the colour table never exceeds 2^8).
const MAX_PALETTE: usize = 256;
const ROP_SRCAND: u32 = 0x0088_00C6;
const ROP_SRCPAINT: u32 = 0x00EE_0086;
const ROP_SRCINVERT: u32 = 0x0066_0046;
/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use.
pub(crate) struct Rgba {
pub w: usize,
pub h: usize,
pub bottom_up: bool,
pub px: Vec<u8>,
}
/// How a decoded bitmap combines with the destination.
#[derive(Clone, Copy)]
pub(crate) enum Blend {
/// A raster operation (BITBLT, STRETCHDIB and friends).
Rop(u32),
/// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set
/// (AC_SRC_ALPHA: the source colours are premultiplied by it).
Alpha { constant: u8, per_pixel: bool },
}
impl Blend {
/// Whether the decoder must keep the 32-bit alpha byte of each pixel.
pub fn per_pixel(self) -> bool {
matches!(self, Blend::Alpha { per_pixel: true, .. })
}
}
struct Header {
w: usize,
h: usize,
top_down: bool,
bpp: usize,
masks: [u32; 3],
pal_off: usize,
pal_n: usize,
}
impl Header {
fn read(info: Bytes) -> Option<Header> {
let size = info.u32(0)? as usize;
let w = info.i32(4)?;
let h = info.i32(8)?;
let bpp = info.u16(14)? as usize;
let comp = info.u32(16)?;
let clr_used = info.u32(32)? as usize;
if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) {
return None;
}
let masks = if comp == BI_BITFIELDS {
[info.u32(40)?, info.u32(44)?, info.u32(48)?]
} else if bpp == 16 {
[0x7C00, 0x03E0, 0x001F]
} else {
[0xFF_0000, 0xFF00, 0xFF]
};
let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 };
let pal_off = size.checked_add(masks_len)?;
// Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the
// entries clrUsed names (WMF DIBs can have them), still capped.
let pal_n = match (bpp <= 8, clr_used) {
(true, 0) => 1usize << bpp,
(true, n) => n.min(1usize << bpp),
(false, n) => n.min(MAX_PALETTE),
};
Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n })
}
}
/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it.
pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
let h = Header::read(dib)?;
h.pal_off.checked_add(h.pal_n.checked_mul(4)?)
}
/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
/// are opaque.
pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option<Rgba> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
if pixels == 0 || pixels > MAX_PIXELS {
return None;
}
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let mut px = vec![0u8; pixels * 4];
for y in 0..hd.h {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for x in 0..hd.w {
let p = pixel(data, row, x, &hd, pal, keep_alpha)?;
let o = (y * hd.w + x) * 4;
px.get_mut(o..o + 4)?.copy_from_slice(&p);
}
}
Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px })
}
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
};
Some([r, g, b, 255])
}
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
fn palette(pal: &[u8], i: usize) -> [u8; 3] {
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down)
/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted
/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the
/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part,
/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors).
pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let (iw, ih) = (img.w as i64, img.h as i64);
let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] };
// The request's top row in top-down order.
let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy };
let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw));
let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih));
if x1 <= x0 || r1 <= r0 {
return None;
}
let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64);
let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky];
let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize);
let (ow, oh) = (x1 - x0, r1 - r0);
let mut px = Vec::with_capacity(ow * oh * 4);
for row in r0..r1 {
let s = (row * img.w + x0) * 4;
px.extend_from_slice(img.px.get(s..s + ow * 4)?);
}
Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out))
}
/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so
/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves
/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other
/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a
/// per-pixel (premultiplied) source is made straight.
pub(crate) fn prepare(img: &mut Rgba, blend: Blend) {
match blend {
Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]),
Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]),
Blend::Rop(_) => {}
Blend::Alpha { constant, per_pixel } => {
for p in img.px.as_chunks_mut::<4>().0 {
if per_pixel {
unpremultiply(p);
}
p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8;
}
}
}
}
/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination.
fn clear_where(img: &mut Rgba, rgb: [u8; 3]) {
for p in img.px.as_chunks_mut::<4>().0 {
if p[..3] == rgb {
p[3] = 0;
}
}
}
/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the
/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes.
fn unpremultiply(p: &mut [u8; 4]) {
let a = u32::from(p[3]);
if a == 0 || a == 255 {
return;
}
for c in &mut p[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
}
}
/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a
/// short buffer flips only the rows it has.
pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) {
let (w, h) = (img.w, img.h);
if w == 0 || h == 0 {
return;
}
let row = w * 4;
if horizontal {
for line in img.px.chunks_exact_mut(row) {
for x in 0..w / 2 {
for c in 0..4 {
line.swap(x * 4 + c, (w - 1 - x) * 4 + c);
}
}
}
}
if vertical {
// Rows the buffer actually holds: a short buffer flips only those.
let n = h.min(img.px.len() / row);
for y in 0..n / 2 {
swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row);
}
}
}
/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range.
fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) {
let Some((head, tail)) = px.split_at_mut_checked(b) else { return };
if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) {
top.swap_with_slice(bottom);
}
}
-478
View File
@@ -1,478 +0,0 @@
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
use std::sync::Arc;
use crate::bytes::Bytes;
use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::dib::{self, Blend};
use crate::geom::{self, ArcKind, P};
use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16};
use crate::{Error, Frame, MAX_PATH_OPS};
/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit
/// characters (EMR_SMALLTEXTOUT).
const ETO_NO_RECT: u32 = 0x0100;
const ETO_SMALL_CHARS: u32 = 0x0200;
const EMF_SIGNATURE: u32 = 0x464D_4520;
/// Hundredths of a millimetre to points.
const HMM_TO_PT: f64 = 72.0 / 2540.0;
/// SRCCOPY, the plain copy of a bitmap.
const SRCCOPY: u32 = 0x00CC_0020;
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
const PS_TYPE_MASK: u32 = 0x000F_0000;
const PS_COSMETIC: u32 = 0;
pub(crate) fn is_emf(b: Bytes) -> bool {
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
}
/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and
/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point).
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
if !is_emf(input) {
return Err(Error::BadHeader);
}
let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize;
let bounds = rect32(input, 8).ok_or(Error::BadHeader)?;
let [l, t, r, b] = bounds;
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
let mut cv = Canvas::new();
if r > l && b > t {
cv.set_clip_surface([l, t, r, b]);
}
if let Some(px_mm) = device_px_per_mm(input) {
cv.set_device_mm(px_mm);
}
let mut plus = crate::emfplus::Plus::new();
plus.frame = frame.as_ref().map(|f| f.rect);
let mut parsed = 0usize;
let mut pos = hsize.max(88);
while !cv.full() {
let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break };
let size = size as usize;
if size < 8 || !size.is_multiple_of(4) {
break;
}
let Some(rec) = input.slice(pos, size) else { break };
if typ == 14 {
break;
}
if typ == 70 && Bytes(rec).u32(12) == Some(crate::emfplus::EMF_PLUS) {
if let Some(data) = Bytes(rec).tail(16) {
plus.comment(&mut cv, data);
}
} else {
record(&mut cv, typ, Bytes(rec));
}
parsed += 1;
let Some(next) = pos.checked_add(size) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
Ok((cv, frame))
}
/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80).
fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
let (dw, dh) = (input.i32(72)?, input.i32(76)?);
let (mw, mh) = (input.i32(80)?, input.i32(84)?);
(dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh)))
}
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
fn frame_from_header(input: Bytes) -> Option<Frame> {
let [fl, ft, fr, fb] = rect32(input, 24)?;
if fr <= fl || fb <= ft {
return None;
}
// Device units per hundredth of a millimetre.
let (px_mm_x, px_mm_y) = device_px_per_mm(input)?;
let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0);
let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy];
Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) })
}
fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
if cv.full() {
return;
}
let _ = match typ {
9 => xy32(r, 8).map(|p| cv.set_window_ext(p)),
10 => xy32(r, 8).map(|p| cv.set_window_org(p)),
11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)),
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
20 => r.u32(8).map(|m| cv.set_rop2(m)),
33 => {
cv.save_dc();
Some(())
}
34 => r.i32(8).map(|n| cv.restore_dc(n)),
35 => xform(r, 8).map(|x| cv.modify_world(x, 4)),
36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)),
37 => r.u32(8).map(|i| cv.select(i)),
38 => create_pen(cv, r),
95 => create_ext_pen(cv, r),
39 => create_brush(cv, r),
93 | 94 => create_dib_brush(cv, r, typ == 93),
82 => create_font(cv, r),
18 => r.u32(8).map(|m| cv.set_bk_mode(m)),
22 => r.u32(8).map(|a| cv.set_text_align(a)),
24 => r.u32(8).map(|c| cv.set_text_color(c)),
25 => r.u32(8).map(|c| cv.set_bk_color(c)),
83 | 84 => ext_text_out(cv, r, typ == 84),
108 => small_text_out(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
29 => rect32(r, 8).map(|rect| cv.exclude_clip_rect(rect)),
30 => rect32(r, 8).map(|rect| cv.intersect_clip_rect(rect)),
75 => ext_select_clip(cv, r),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
44 => {
let (cw, ch) = (r.i32(24), r.i32(28));
let (Some(cw), Some(ch)) = (cw, ch) else { return };
rect32(r, 8).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true);
})
}
45..=47 => arc32(r).map(|(bounds, s, e)| {
let kind = match typ {
45 => ArcKind::Arc,
46 => ArcKind::Chord,
_ => ArcKind::Pie,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)),
3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)),
6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)),
// POLYBEZIER starts its own figure at the first point and leaves the current point alone;
// POLYBEZIERTO continues from the current point.
2 | 85 => counted(r, typ == 2).and_then(|pts| {
let from = *pts.first()?;
cv.bezier(from, pts.get(1..)?);
Some(())
}),
5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)),
7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)),
8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)),
59 => {
cv.begin_path();
Some(())
}
60 => {
cv.end_path();
Some(())
}
61 => {
cv.close_figure();
Some(())
}
62 => {
cv.paint_path(true, false);
Some(())
}
63 => {
cv.paint_path(true, true);
Some(())
}
64 => {
cv.paint_path(false, true);
Some(())
}
76 | 77 => bitblt(cv, r, typ == 77),
80 => set_dibits(cv, r),
81 => stretch_dibits(cv, r),
114 => alphablend(cv, r),
_ => Some(()),
};
}
/// EMR_EXTSELECTCLIPRGN: `cbRgnData` bytes of `RGNDATA` (rectangles in logical units) and a region
/// mode. No region data selects the default clip.
fn ext_select_clip(cv: &mut Canvas, r: Bytes) -> Option<()> {
let cb = r.u32(8)? as usize;
let mode = r.u32(12)?;
if cb == 0 {
cv.reset_clip();
return Some(());
}
let header = 16;
if cb < 32 {
return Some(());
}
// RDH_RECTANGLES is the only region type GDI writes.
if r.u32(header + 4)? != 1 {
return Some(());
}
let n = (r.u32(header + 8)? as usize).min(crate::clip::MAX_CLIP_RECTS);
let end = header.checked_add(cb)?;
let mut rects = Vec::new();
for i in 0..n {
let o = header + 32 + i * 16;
if o + 16 > end {
break;
}
rects.push(rect32(r, o)?);
}
cv.select_clip_rects(&rects, mode);
Some(())
}
/// Two little-endian i32 values at `off`, as a point.
fn xy32(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
}
/// RECTL (left, top, right, bottom) as i32 values widened to f64.
fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> {
Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)])
}
/// An i32 field as f64; 0 past the end.
fn i32s(r: Bytes, off: usize) -> f64 {
r.i32(off).map_or(0.0, f64::from)
}
/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order.
fn xform(r: Bytes, off: usize) -> Option<Xform> {
let mut x = [0.0f64; 6];
for (i, v) in x.iter_mut().enumerate() {
*v = f64::from(r.f32(off + i * 4)?);
}
Some(x)
}
/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32.
fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> {
Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?))
}
/// A point count from a record, rejected when it exceeds the path budget (before any allocation).
fn budget(n: u32) -> Option<usize> {
let n = n as usize;
(n <= MAX_PATH_OPS).then_some(n)
}
/// Point count at 24, then the points (32-bit or 16-bit) at 28.
fn counted(r: Bytes, wide: bool) -> Option<Vec<P>> {
let n = budget(r.u32(24)?)?;
r.tail(28)?.points(0, n, wide)
}
/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points.
fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec<usize>, Vec<P>)> {
let n = budget(r.u32(24)?)?;
// The record must hold the n counts before anything is allocated for them.
let raw = r.slice(32, n.checked_mul(4)?)?;
let counts: Vec<usize> = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect();
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
let total = budget(u32::try_from(total).ok()?)?;
let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?;
Some((counts, pts))
}
/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour).
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let width = r.i32(16)?;
let color = r.u32(24)?;
cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) }));
Some(())
}
/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour).
/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws
/// no line at all.
fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?);
let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) };
let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK };
cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) }));
Some(())
}
/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let color = r.u32(16)?;
let mut b = Brush::new(style, colorref(color));
if style == BS_HATCHED {
b.hatch = r.u32(20).unwrap_or(0);
}
cv.add_object(Some(slot), Obj::Brush(b));
Some(())
}
/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the
/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices,
/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot
/// empty.
fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> {
let slot = r.u32(8)? as usize;
let usage = r.u32(12)?;
let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize);
let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize);
let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?;
let b = Brush::pattern(img, mono || usage == 1)?;
cv.add_object(Some(slot), Obj::Brush(b));
Some(())
}
/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement,
/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then
/// the face name in 32 UTF-16 units (NUL-terminated).
fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let height = r.i32(12)?;
let width = r.i32(16).unwrap_or(0);
let escapement = r.i32(20).unwrap_or(0);
let weight = r.i32(28).unwrap_or(0);
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
let units: Vec<u16> = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect();
let (family, _) = decode_utf16(&units, &[]);
let font = Font {
family: family.trim().to_string(),
height: height as f32,
width: width as f32,
weight: weight.clamp(0, 1000) as u16,
italic: byte(32) != 0,
underline: byte(33) != 0,
strike: byte(34) != 0,
charset: byte(35),
};
cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) })));
Some(())
}
/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only
/// dx is used). Empty when absent or short.
fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec<f32> {
let step = if opts & ETO_PDY != 0 { 8 } else { 4 };
if off == 0 {
return Vec::new();
}
let dx: Vec<f32> = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect();
if dx.len() == n { dx } else { Vec::new() }
}
/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText
/// at 36: reference point, character count, string offset, options, the rectangle (absent under
/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name
/// glyphs of a font the reader cannot match, so they are left out.
fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> {
let at = xy32(r, 36)?;
let n = (r.u32(44)? as usize).min(MAX_CHARS);
let off = r.u32(48)? as usize;
let opts = r.u32(52)?;
let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) };
if opts & ETO_GLYPH_INDEX != 0 {
return None;
}
let dx = dx_array(r, dx_off as usize, n, opts);
let (text, dx) = if wide {
let raw = r.slice(off, n.checked_mul(2)?)?;
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
decode_utf16(&units, &dx)
} else {
decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx)
};
cv.text(at, text, dx, opts, rect);
Some(())
}
/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the
/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16.
fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let at = xy32(r, 8)?;
let n = (r.u32(16)? as usize).min(MAX_CHARS);
let opts = r.u32(20)?;
if opts & ETO_GLYPH_INDEX != 0 {
return None;
}
let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) };
let (text, _) = if opts & ETO_SMALL_CHARS != 0 {
decode_bytes(r.slice(off, n)?, cv.font_charset(), &[])
} else {
let raw = r.slice(off, n.checked_mul(2)?)?;
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
decode_utf16(&units, &[])
};
cv.text(at, text, Vec::new(), opts, rect);
Some(())
}
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let rop = r.u32(40)?;
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) };
let src = [i32s(r, 44), i32s(r, 48), sw, sh];
blit(cv, r, dst, src, (84, 92), Blend::Rop(rop))
}
/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to
/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha.
fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let constant = *r.0.get(42)?;
let per_pixel = (*r.0.get(43)? & 0x01) != 0;
let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)];
blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel })
}
/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68,
/// dest w/h at 72.
fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)];
let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)];
let rop = r.u32(68)?;
blit(cv, r, dst, src, (48, 56), Blend::Rop(rop))
}
/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi
/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line
/// `iStartScan`, so the source y counts from there.
fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let (cx, cy) = (i32s(r, 40), i32s(r, 44));
if cx <= 0.0 || cy <= 0.0 {
return None;
}
let dst = [i32s(r, 24), i32s(r, 28), cx, cy];
let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy];
blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY))
}
/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both
/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a
/// non-positive source size means the whole bitmap. `blend` decides how it combines.
fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> {
let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize);
let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize);
if pl == 0 {
return None;
}
let info = Bytes(r.slice(bo, bl)?);
let pixels = Bytes(r.slice(po, pl)?);
let mut img = dib::decode(info, pixels, blend.per_pixel())?;
dib::prepare(&mut img, blend);
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
File diff suppressed because it is too large Load Diff
-118
View File
@@ -1,118 +0,0 @@
//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs
//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece).
use std::f64::consts::{FRAC_PI_2, PI, TAU};
pub(crate) type P = (f64, f64);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Op {
M(P),
L(P),
C(P, P, P),
Z,
}
pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z]
}
/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap.
pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec<Op> {
// Reversed corners (right < left, bottom < top) describe the same rectangle.
let (l, r) = (l.min(r), l.max(r));
let (t, b) = (t.min(b), t.max(b));
let rx = (ew / 2.0).abs().min((r - l) / 2.0);
let ry = (eh / 2.0).abs().min((b - t) / 2.0);
let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))];
ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((r, b - ry)));
ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2));
ops.push(Op::L((l + rx, b)));
ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((l, t + ry)));
ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2));
ops.push(Op::Z);
ops
}
pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let mut ops = vec![Op::M((cx + rx, cy))];
ops.extend(arc(cx, cy, rx, ry, 0.0, TAU));
ops.push(Op::Z);
ops
}
/// Which closed or open shape an ARC/CHORD/PIE record draws.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ArcKind {
Arc,
Chord,
Pie,
}
/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point.
pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE));
let (a0, a1) = (ang(s), ang(e));
let mut d = (a0 - a1).rem_euclid(TAU);
if d < 1e-12 {
d = TAU;
}
let sweep = -d;
let start = (cx + rx * a0.cos(), cy + ry * a0.sin());
let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] };
ops.extend(arc(cx, cy, rx, ry, a0, sweep));
match kind {
ArcKind::Arc => {}
ArcKind::Chord => ops.push(Op::Z),
ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]),
}
ops
}
fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) {
((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0)
}
/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point
/// must already be at `(cx + rx cos a0, cy + ry sin a0)`.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec<Op> {
let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8);
let step = sweep / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin());
let mut ops = Vec::with_capacity(n);
for i in 0..n {
let t0 = a0 + step * i as f64;
let t1 = t0 + step;
let (p0, p3) = (pt(t0), pt(t1));
let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos());
let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos());
ops.push(Op::C(c1, c2, p3));
}
ops
}
/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the
/// point list are cut short rather than failing the whole record.
pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
let mut ops = Vec::with_capacity(pts.len() + counts.len());
let mut at = 0usize;
for &c in counts {
let end = at.saturating_add(c).min(pts.len());
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
at = end;
continue;
};
ops.push(Op::M(*first));
ops.extend(rest.iter().map(|p| Op::L(*p)));
if close {
ops.push(Op::Z);
}
at = end;
}
ops
}
-306
View File
@@ -1,306 +0,0 @@
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern
//! fills, RGBA bitmaps and text strings, in a picture frame with y down.
//!
//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the
//! string, its advances and alignment; the renderer resolves the font and draws the glyphs.
//!
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
//! never a panic.
//!
//! Records follow MS-WMF and MS-EMF. Rectangle clip regions (`INTERSECTCLIPRECT`,
//! `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit what later records draw. Records outside the
//! supported set are skipped silently.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bytes;
mod canvas;
mod clip;
mod cliprect;
mod dib;
mod emf;
mod emfplus;
mod geom;
mod place;
mod text;
mod wmf;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use canvas::Raw;
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign};
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
/// Object table slots (WMF handles, EMF object indices).
pub(crate) const MAX_OBJECTS: usize = 4096;
/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA).
pub(crate) const MAX_PIXELS: usize = 40_000_000;
/// DC save-stack depth.
pub(crate) const MAX_STACK: usize = 1024;
/// Points in one open path bracket.
pub(crate) const MAX_PATH_OPS: usize = 2_000_000;
/// Bytes of path ops and pixels kept from one file (about 256 MB).
pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024;
/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
/// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device
/// or logical units otherwise.
pub width: f32,
pub height: f32,
pub items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Item {
/// A filled and/or stroked path, in picture coordinates.
Path {
segs: Vec<Seg>,
fill: Option<[u8; 4]>,
/// Colour and width in picture units.
stroke: Option<([u8; 4], f32)>,
even_odd: bool,
/// Clip region in picture units, each rectangle `[x0, y0, x1, y1]`. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap {
rect: [f32; 4],
width: u32,
height: u32,
rgba: Vec<u8>,
/// Clip region in picture units. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same
/// brush share its tile.
Pattern {
segs: Vec<Seg>,
even_odd: bool,
tile: Arc<Tile>,
/// Clip region in picture units. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A text string.
Text(Box<Text>),
}
/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each
/// pixel `cell` picture units, with a tile's top-left corner at `origin`.
#[derive(Debug, Clone, PartialEq)]
pub struct Tile {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
pub cell: [f32; 2],
pub origin: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Seg {
Move(f32, f32),
Line(f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum Error {
#[error("not a WMF or EMF file")]
NotMetafile,
#[error("metafile header is truncated or invalid")]
BadHeader,
#[error("metafile has no readable records")]
NoRecords,
}
/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its
/// size in picture units.
pub(crate) struct Frame {
pub rect: [f64; 4],
pub size: Option<(f64, f64)>,
}
/// Parses a WMF (placeable or plain) or EMF file.
pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
let b = Bytes(bytes);
let (cv, frame) = if emf::is_emf(b) {
emf::parse(b)?
} else if wmf::is_wmf(b) {
// A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws
// the WMF's own records (its dx rounding shows), so the copy is not used.
wmf::parse(b)?
} else {
return Err(Error::NotMetafile);
};
Ok(build(cv.items, frame))
}
/// True when the bytes start like a WMF (placeable or plain header) or an EMF.
pub fn is_metafile(bytes: &[u8]) -> bool {
let b = Bytes(bytes);
emf::is_emf(b) || wmf::is_wmf(b)
}
/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding
/// box of the drawing, so the origin is still the top-left of what was drawn.
fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0);
let (rect, size) = match frame {
Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))),
None => match bbox(&raw) {
Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)),
None => return Picture { width: 0.0, height: 0.0, items: Vec::new() },
},
};
let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 };
let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 };
let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy);
let stroke_scale = (sx + sy) / 2.0;
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
let diag = size.0.hypot(size.1);
// Tiles shared by several fills stay shared.
let mut tiles: HashMap<*const canvas::RawTile, (Arc<canvas::RawTile>, Arc<Tile>)> = HashMap::new();
let items = raw.into_iter().filter_map(|r| match r {
Raw::Path { ops, fill, stroke, even_odd, region } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
Some(Item::Path { segs, fill, stroke, even_odd, region: map_region(region, &tf)? })
}
Raw::Bitmap { rect: r, img, region } => {
let (x, y) = tf((r[0], r[1]));
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px, region: map_region(region, &tf)? })
}
Raw::Pattern { ops, even_odd, tile, region } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let key = Arc::as_ptr(&tile);
let tile = match tiles.get(&key) {
Some((_, t)) => t.clone(),
None => {
let (ox, oy) = tf((0.0, 0.0));
let t = Arc::new(Tile {
width: u32::try_from(tile.w).ok()?,
height: u32::try_from(tile.h).ok()?,
rgba: tile.px.clone(),
cell: [f(tile.cell * sx)?, f(tile.cell * sy)?],
origin: [f(ox)?, f(oy)?],
});
// The raw tile is kept with its key, so the address is never reused by another.
tiles.insert(key, (tile.clone(), t.clone()));
t
}
};
Some(Item::Pattern { segs, even_odd, tile, region: map_region(region, &tf)? })
}
Raw::Text { mut text, region } => {
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
let [a, b, c, d, e, g] = text.transform;
let (e, g) = tf((e, g));
text.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
if !text.transform.iter().all(|v| v.is_finite()) {
return None;
}
if let Some([x0, y0, x1, y1]) = text.clip {
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
text.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
}
text.region = map_region(region, &tf)?;
Some(Item::Text(text))
}
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
}
fn map_region(region: Option<Vec<[f64; 4]>>, tf: &impl Fn(P) -> P) -> Option<Option<Vec<[f32; 4]>>> {
let Some(rects) = region else { return Some(None) };
let mut out = Vec::with_capacity(rects.len());
for [x0, y0, x1, y1] in rects {
let (p, q) = (tf((x0, y0)), tf((x1, y1)));
out.push([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
}
Some(Some(out))
}
fn f(v: f64) -> Option<f32> {
let v = v as f32;
v.is_finite().then_some(v)
}
fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option<Seg> {
let pt = |p: P| {
let (x, y) = tf(p);
Some((f(x)?, f(y)?))
};
Some(match *op {
Op::M(p) => {
let (x, y) = pt(p)?;
Seg::Move(x, y)
}
Op::L(p) => {
let (x, y) = pt(p)?;
Seg::Line(x, y)
}
Op::C(a, b, c) => {
let (x1, y1) = pt(a)?;
let (x2, y2) = pt(b)?;
let (x3, y3) = pt(c)?;
Seg::Cubic(x1, y1, x2, y2, x3, y3)
}
Op::Z => Seg::Close,
})
}
/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output.
fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
let mut b: Option<[f64; 4]> = None;
let mut grow = |p: P| {
if p.0.is_finite() && p.1.is_finite() {
b = Some(match b {
None => [p.0, p.1, p.0, p.1],
Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)],
});
}
};
for r in raw {
match r {
Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => {
for op in ops {
match *op {
Op::M(p) | Op::L(p) => grow(p),
Op::C(a, c, d) => {
grow(a);
grow(c);
grow(d);
}
Op::Z => {}
}
}
}
Raw::Bitmap { rect, .. } => {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
Raw::Text { text: t, .. } => grow((t.transform[4], t.transform[5])),
}
}
b
}
-195
View File
@@ -1,195 +0,0 @@
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
use std::sync::Arc;
use kurbo::{Affine, BezPath, Rect, Shape};
use crate::{Item, Picture, Seg};
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
pub const MAX_BITMAP_SIDE: u32 = 30_000;
/// Placed coordinates past this (target units) are far off any page; such items are left out.
const MAX_COORD: f64 = 1e6;
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
const MIN_BITMAP_SIDE: f64 = 1e-6;
/// Maximum aggregate path elements retained in placed output.
const MAX_PLACED_ELEMENTS: usize = 500_000;
/// One item of a placed picture, in target units.
pub enum PlacedItem {
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
/// the renderer draws at its minimum width.
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, region: Option<Arc<BezPath>> },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect, region: Option<Arc<BezPath>> },
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
/// repeated; `tile` maps tile pixels to target units.
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine, region: Option<Arc<BezPath>> },
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
/// `clip` (target units) bounds what it draws. `region` is the device-context clip.
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<Arc<BezPath>> },
}
impl Picture {
/// Whether the picture has a finite, positive size, so it can be fitted into a frame.
pub fn has_size(&self) -> bool {
self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0
}
/// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right,
/// bottom) removed first: the visible part fills `r`. None for a degenerate size.
pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option<Affine> {
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 });
let (w, h) = (f64::from(self.width), f64::from(self.height));
let sx = r.width() / (w * (1.0 - cl - cr).max(0.05));
let sy = r.height() / (h * (1.0 - ct - cb).max(0.05));
(sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0)
.then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy))
}
/// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6
/// target units are left out (they are far off any page), as are bitmaps with no area. Stroke
/// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps
/// strokes even).
pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec<PlacedItem> {
let Some(tf) = self.fit(r, crop) else { return Vec::new() };
let c = tf.as_coeffs();
let k = (c[0].abs() + c[3].abs()) / 2.0;
let mut out = Vec::with_capacity(self.items.len());
let mut total_elements = 0usize;
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
let (ptr, len) = match reg {
None => (std::ptr::null(), 0),
Some(v) => (v.as_ptr(), v.len()),
};
if let Some((c_ptr, c_len, ref res)) = cached_region
&& c_ptr == ptr
&& c_len == len
{
return res.clone();
}
let res = placed_region(&tf, reg);
cached_region = Some((ptr, len, res.clone()));
res
};
for (index, item) in self.items.iter().enumerate() {
if total_elements >= MAX_PLACED_ELEMENTS {
break;
}
match item {
Item::Path { segs, fill, stroke, even_odd, region } => {
let path = tf * bez(segs);
let Some(region) = get_region(region) else { continue };
if in_range(path.bounding_box()) {
total_elements = total_elements.saturating_add(path.elements().len());
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
}
}
Item::Pattern { segs, even_odd, tile: t, region } => {
let path = tf * bez(segs);
let Some(region) = get_region(region) else { continue };
let tile = tf
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
let det = tile.determinant();
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
total_elements = total_elements.saturating_add(path.elements().len());
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
}
}
Item::Text(t) => {
let transform = tf * Affine::new(t.transform);
let Some(region) = get_region(&t.region) else { continue };
let clip =
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
let c = transform.as_coeffs();
let origin = Rect::new(c[4], c[5], c[4], c[5]);
if in_range(origin) && c.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) && clip.is_none_or(in_range) {
out.push(PlacedItem::Text { index, transform, clip, region });
}
}
Item::Bitmap { rect: [x, y, w, h], region, .. } => {
let (x0, y0) = (f64::from(*x), f64::from(*y));
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
let Some(region) = get_region(region) else { continue };
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
out.push(PlacedItem::Bitmap { index, rect, region });
}
}
}
}
out
}
}
impl Item {
/// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height,
/// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many
/// pixels. None for paths and for bitmaps that cannot be drawn.
pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let Item::Bitmap { width, height, rgba, .. } = self else { return None };
let (w, h, px) = (*width, *height, std::mem::take(rgba));
let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h);
(sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px))
}
}
/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the
/// region does not meet the page, so the item draws nothing.
fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<Arc<BezPath>>> {
let Some(rects) = region else { return Some(None) };
let mut path = BezPath::new();
for [x0, y0, x1, y1] in rects {
let r = tf.transform_rect_bbox(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1)));
if in_range(r) && r.width() > 1e-9 && r.height() > 1e-9 {
path.extend(r.to_path(0.1));
}
}
(!path.is_empty()).then_some(Some(Arc::new(path)))
}
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
fn bez(segs: &[Seg]) -> BezPath {
let mut p = BezPath::new();
let mut start = (0.0, 0.0);
let mut open = false;
for s in segs {
match *s {
Seg::Move(x, y) => {
start = (f64::from(x), f64::from(y));
p.move_to(start);
open = true;
}
Seg::Line(x, y) => {
if !open {
p.move_to(start);
}
p.line_to((f64::from(x), f64::from(y)));
open = true;
}
Seg::Cubic(a, b, c, d, e, f) => {
if !open {
p.move_to(start);
}
p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f)));
open = true;
}
Seg::Close => {
if open {
p.close_path();
}
open = false;
}
}
}
p
}
/// Whether a placed box is finite and within ±[`MAX_COORD`].
fn in_range(b: Rect) -> bool {
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD)
}
File diff suppressed because it is too large Load Diff
-203
View File
@@ -1,203 +0,0 @@
//! Text records: the font (LOGFONT), the text string decoded from its character set, and the
//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the
//! font's metrics and glyph drawing are left to the renderer.
/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Font {
/// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty.
pub family: String,
/// LOGFONT height in text units: negative is the character (em) height, positive the cell
/// height (ascent plus descent), 0 the default size.
pub height: f32,
/// Average character width in text units; 0 keeps the font's own proportions.
pub width: f32,
/// 100..=900 (400 regular, 700 bold); 0 is the default weight.
pub weight: u16,
pub italic: bool,
pub underline: bool,
pub strike: bool,
/// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…).
pub charset: u8,
}
/// SYMBOL_CHARSET: byte codes are the font's own symbol codes.
pub const SYMBOL_CHARSET: u8 = 2;
/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HAlign {
#[default]
Left,
Center,
Right,
}
/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the
/// character cell, the baseline or the bottom of the cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VAlign {
#[default]
Top,
Baseline,
Bottom,
}
/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT.
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
/// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`,
/// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the
/// metafile's logical units, with the reference point at the origin. Escapement and the
/// mapping are folded in; glyphs are never mirrored.
pub transform: [f64; 6],
pub text: String,
/// Advance after each character of `text`, in text units (the dx array). Empty when the file
/// gives none: the font's own advances apply.
pub dx: Vec<f32>,
pub font: Font,
pub color: [u8; 4],
/// Colour filled behind the character cells (OPAQUE background mode); None when transparent.
pub background: Option<[u8; 4]>,
pub h_align: HAlign,
pub v_align: VAlign,
/// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED).
pub clip: Option<[f32; 4]>,
/// Clip region from the device context, in picture units. None is the default clip. Drawn
/// together with [`Text::clip`] when both are set.
pub region: Option<Vec<[f32; 4]>>,
}
/// TA_UPDATECP: the string starts at the current position, which moves past it.
pub(crate) const TA_UPDATECP: u32 = 0x0001;
/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it.
pub(crate) const ETO_OPAQUE: u32 = 0x0002;
pub(crate) const ETO_CLIPPED: u32 = 0x0004;
/// The string holds glyph indices, not characters (drawn only when the font matches).
pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010;
/// The dx array holds (dx, dy) pairs.
pub(crate) const ETO_PDY: u32 = 0x2000;
/// Longest string kept from one record.
pub(crate) const MAX_CHARS: usize = 16_384;
/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom,
/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center).
pub(crate) fn align(ta: u32) -> (HAlign, VAlign) {
let h = match ta & 0x06 {
6 => HAlign::Center,
2 => HAlign::Right,
_ => HAlign::Left,
};
let v = match ta & 0x18 {
0x18 => VAlign::Baseline,
0x08 => VAlign::Bottom,
_ => VAlign::Top,
};
(h, v)
}
/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a
/// double-byte character set's lead and trail bytes become one blank (its glyphs are not
/// decoded) whose advance is the sum of both. `dx` may be empty.
pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec<f32>) {
let mut s = String::with_capacity(bytes.len());
let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len()));
let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136);
let mut i = 0;
while let Some(&b) = bytes.get(i) {
let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 };
let c = if n == 2 { ' ' } else { byte_char(b, charset) };
s.push(c);
if !dx.is_empty() {
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
}
i += n;
}
(s, out_dx)
}
/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of
/// its two advances. Unpaired surrogates become U+FFFD.
pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec<f32>) {
let mut s = String::with_capacity(units.len());
let mut out_dx = Vec::with_capacity(dx.len().min(units.len()));
let mut i = 0;
for r in char::decode_utf16(units.iter().copied()) {
let c = r.unwrap_or(char::REPLACEMENT_CHARACTER);
let n = c.len_utf16();
s.push(c);
if !dx.is_empty() {
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
}
i += n;
}
(s, out_dx)
}
/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use
/// range (U+F000 + code), where symbol fonts map them.
fn byte_char(b: u8, charset: u8) -> char {
if b < 0x80 && charset != SYMBOL_CHARSET {
return char::from(b);
}
let cp = match charset {
SYMBOL_CHARSET => 0xF000 | u32::from(b),
238 => high(b, &CP1250),
204 => high(b, &CP1251),
_ => high(b, &CP1252),
};
char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER)
}
/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are
/// undefined bytes, drawn as U+FFFD.
fn high(b: u8, table: &[u16; 128]) -> u32 {
match table.get(usize::from(b.wrapping_sub(0x80))) {
Some(0) | None => 0xFFFD,
Some(v) => u32::from(*v),
}
}
/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F.
const CP1252: [u16; 128] = {
let mut t = [0u16; 128];
let low: [u16; 32] = [
0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C,
0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
];
let mut i = 0;
while i < 128 {
t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 };
i += 1;
}
t
};
/// Windows-1250 (Central European) 0x80..=0xFF.
const CP1250: [u16; 128] = [
0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, //
0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, //
0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, //
0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, //
0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, //
0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, //
0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, //
0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9,
];
/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order.
const CP1251: [u16; 128] = {
let low: [u16; 64] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, //
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, //
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, //
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
];
let mut t = [0u16; 128];
let mut i = 0;
while i < 128 {
t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) };
i += 1;
}
t
};
-383
View File
@@ -1,383 +0,0 @@
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
use std::sync::Arc;
use crate::bytes::Bytes;
use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref};
use crate::dib;
use crate::geom::{self, ArcKind, P};
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes};
use crate::{Error, Frame, MAX_PATH_OPS};
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const DEFAULT_INCH: f64 = 1440.0;
/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels.
const PT_PER_PX: f64 = 72.0 / 96.0;
pub(crate) fn is_wmf(b: Bytes) -> bool {
if b.u32(0) == Some(PLACEABLE_KEY) {
return true;
}
matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300))
}
/// Reads all records. Returns the drawing state and the picture frame, or an error when no record
/// could be read at all.
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let placeable = input.u32(0) == Some(PLACEABLE_KEY);
// Placeable bounds: logical corners (left, top, right, bottom) and their size in points.
let (hdr, bounds) = if placeable {
let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from));
let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) };
let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
let (w, h) = (r - l, b - t);
let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch)));
(22, bounds)
} else {
(0, None)
};
let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize;
if hsize < 9 {
return Err(Error::BadHeader);
}
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
// Played into a target like Word does: the placeable bounds are the first window, the target (the
// picture size) the viewport. A plain WMF's target is its first window.
let mut cv = Canvas::new();
if let Some((c, size)) = bounds {
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
cv.set_clip_surface([0.0, 0.0, size.0, size.1]);
cv.pattern_px = PT_PER_PX;
}
let mut player_viewport = bounds.is_some();
let mut parsed = 0usize;
while !cv.full() {
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break };
let Some(rec) = input.slice(pos, len) else { break };
if func == 0 {
break;
}
record(&mut cv, func, Bytes(rec));
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
let we = cv.map.window_ext();
cv.play_into(None, (we.0.abs(), we.1.abs()));
player_viewport = true;
}
parsed += 1;
let Some(next) = pos.checked_add(len) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
let frame = match bounds {
// Played into its target: the frame is the target.
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
// The file set its own viewport, so the drawing is in its device units and the logical bounds
// go through the final mapping.
Some((c, size)) => {
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
}
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
let ve = cv.map.viewport_ext();
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
}
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
None => None,
};
Ok((cv, frame))
}
fn record(cv: &mut Canvas, func: u16, r: Bytes) {
if cv.full() {
return;
}
let _ = match func {
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
// Y is stored before X in these records.
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)),
0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)),
0x001E => {
cv.save_dc();
Some(())
}
0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))),
0x0214 => xy16(r, 6).map(|p| cv.move_to(p)),
0x0213 => xy16(r, 6).map(|p| cv.line_to(p)),
0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)),
0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)),
0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)),
0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
0x061C => {
let [ew, eh] = [r.i16(8), r.i16(6)];
let (Some(ew), Some(eh)) = (ew, eh) else { return };
rect16(r, 10).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
})
}
0x0415 => rect16(r, 6).map(|rect| cv.exclude_clip_rect(rect)),
0x0416 => rect16(r, 6).map(|rect| cv.intersect_clip_rect(rect)),
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
let kind = match func {
0x0817 => ArcKind::Arc,
0x081A => ArcKind::Pie,
_ => ArcKind::Chord,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
0x02FA => create_pen(cv, r),
0x02FC => create_brush(cv, r),
0x02FB => {
let f = font(r).unwrap_or_default();
cv.add_object(None, Obj::Font(Arc::new(f)));
Some(())
}
0x0142 => {
// A pattern the reader cannot decode still takes its handle slot.
let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush);
cv.add_object(None, b);
Some(())
}
// Palettes, old-style pattern brushes and regions take a handle slot too, so later
// SELECTOBJECT handles count them; selecting one draws nothing.
0x00F7 | 0x01F9 | 0x06FF => {
cv.add_object(None, Obj::Other);
Some(())
}
0x0209 => r.u32(6).map(|c| cv.set_text_color(c)),
0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)),
0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))),
0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))),
0x0A32 => ext_text_out(cv, r),
0x0521 => text_out(cv, r),
// PATBLT: rop(4), height, width, y, x.
0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)),
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
0x0F43 => {
let rop = r.u32(6).unwrap_or(0);
dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)])
}
0x0B41 => dib_stretchblt(cv, r),
0x0940 => dib_bitblt(cv, r),
_ => Some(()),
};
}
/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the
/// canvas reads with the 32-bit stock flag.
fn wmf_handle(h: u16) -> u32 {
let h = u32::from(h);
if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h }
}
/// An i16 field as f64; 0 past the end.
fn i16s(r: Bytes, off: usize) -> f64 {
r.i16(off).map_or(0.0, f64::from)
}
/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used.
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = r.u16(6)?;
let width = r.i16(8)?;
let color = r.u32(12)?;
cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) }));
Some(())
}
/// CREATEBRUSHINDIRECT: style, colour, hatch.
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = u32::from(r.u16(6)?);
let color = r.u32(8)?;
let mut b = Brush::new(style, colorref(color));
if style == BS_HATCHED {
b.hatch = u32::from(r.u16(12).unwrap_or(0));
}
cv.add_object(None, Obj::Brush(b));
Some(())
}
/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette
/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a
/// monochrome DIB takes the text and background colours.
fn dib_pattern_brush(r: Bytes) -> Option<Brush> {
let usage = r.u16(8)?;
let dib = r.tail(10)?;
let n = dib::info_len(dib)?;
let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?;
Brush::pattern(img, usage == 1)
}
/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic,
/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the
/// face name (up to 32 bytes, NUL-terminated, in the font's character set).
fn font(r: Bytes) -> Option<LogFont> {
let height = r.i16(6)?;
let width = r.i16(8).unwrap_or(0);
let escapement = r.i16(10).unwrap_or(0);
let weight = r.i16(14).unwrap_or(0);
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
let charset = byte(19);
let name = r.0.get(24..).unwrap_or(&[]);
let name = name.get(..32).unwrap_or(name);
let name = name.split(|b| *b == 0).next().unwrap_or(&[]);
// Face names are plain ASCII in practice; other bytes go through the Western code page.
let (family, _) = decode_bytes(name, 0, &[]);
Some(LogFont {
font: Font {
family: family.trim().to_string(),
height: f32::from(height),
width: f32::from(width),
weight: weight.clamp(0, 1000) as u16,
italic: byte(16) != 0,
underline: byte(17) != 0,
strike: byte(18) != 0,
charset,
},
escapement: f64::from(escapement),
})
}
/// The charset of the selected font, for decoding 8-bit text.
fn charset(cv: &Canvas) -> u8 {
cv.font_charset()
}
/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right,
/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx
/// array of i16 advances.
fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let (y, x) = (r.i16(6)?, r.i16(8)?);
let n = usize::from(r.u16(10)?).min(MAX_CHARS);
let opts = u32::from(r.u16(12)?);
let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0;
let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None };
let at = if has_rect { 22 } else { 14 };
// A string running past the record is cut to what the record holds.
let avail = r.0.len().saturating_sub(at);
let n = n.min(avail);
let bytes = r.slice(at, n)?;
let dx_at = at + n.next_multiple_of(2);
let dx: Vec<f32> = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() };
let (text, dx) = decode_bytes(bytes, charset(cv), &dx);
cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect);
Some(())
}
/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x.
fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let n = usize::from(r.u16(6)?).min(MAX_CHARS);
let bytes = r.slice(8, n)?;
let at = 8 + n.next_multiple_of(2);
let (y, x) = (r.i16(at)?, r.i16(at + 2)?);
let (text, _) = decode_bytes(bytes, charset(cv), &[]);
cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None);
Some(())
}
/// (x, y) from a Y-then-X pair stored at `off`.
fn xy16(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?)))
}
/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE).
fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> {
let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?);
Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)])
}
/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left).
fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> {
let end = xy16(r, 6)?;
let start = xy16(r, 10)?;
let bounds = rect16(r, 14)?;
Some((bounds, start, end))
}
/// A count of 16-bit points, then the points (POLYLINE, POLYGON).
fn counted_points(r: Bytes) -> Option<Vec<P>> {
let n = r.u16(6)? as usize;
r.tail(8)?.points(0, n, false)
}
/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points.
fn polypolygon(r: Bytes) -> Option<Vec<geom::Op>> {
let n = r.u16(6)? as usize;
let mut counts = Vec::with_capacity(n);
for i in 0..n {
counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize);
}
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
if total > MAX_PATH_OPS {
return None;
}
let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?;
Some(geom::subpaths(&pts, &counts, true))
}
/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly
/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3).
fn has_bitmap(func: u16, r: Bytes) -> bool {
r.0.len() / 2 > usize::from(func >> 8) + 3
}
/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The
/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or
/// a source-less raster operation, fills the destination.
fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0940, r) {
cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop);
return Some(());
}
let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]];
dib_blit(cv, r, 22, rop, dst, src)
}
/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at
/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later;
/// it, or a source-less raster operation, fills the destination.
fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0B41, r) {
cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop);
return Some(());
}
let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)];
dib_blit(cv, r, 26, rop, dst, src)
}
/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`).
/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]).
fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> {
let dib = r.tail(at)?;
let n = dib::info_len(dib)?;
let info = dib.slice(0, n)?;
let bits = dib.tail(n)?;
let mut img = dib::decode(Bytes(info), bits, false)?;
dib::prepare(&mut img, dib::Blend::Rop(rop));
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
+94 -34
View File
@@ -392,6 +392,9 @@ pub struct Chart {
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// The chart was too large to model: `raw` and `kept` are written back unchanged on save.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub verbatim: bool,
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
/// The PPTX writer clears this before comparing modelled state. Native saves keep it too.
#[serde(skip_serializing_if = "ChartKept::is_empty")]
@@ -485,6 +488,7 @@ impl Default for Chart {
bin_count: None,
bin_size: None,
raw: None,
verbatim: false,
kept: ChartKept::default(),
}
}
@@ -758,17 +762,20 @@ impl Chart {
}
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
for i in 0..n {
let (mut p, mut m) = (0.0, 0.0);
for s in &self.series {
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
if v >= 0.0 { p += v } else { m += v }
// Sums per category, from the values the series hold (an absent one adds nothing).
let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
for s in &self.series {
for (i, v) in s.values.iter().enumerate().take(n) {
let v = v.unwrap_or(0.0);
if v >= 0.0 {
pos[i] += v;
} else {
neg[i] += v;
}
}
hi = hi.max(p);
lo = lo.min(m);
}
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
(lo, hi)
}
@@ -1308,6 +1315,7 @@ impl Chart {
series: vec![],
gap_width: 0.0,
raw: None,
verbatim: false,
subtotals: vec![],
..self.clone()
};
@@ -1348,40 +1356,50 @@ impl Chart {
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no
/// points).
///
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
let mut groups: Vec<String> = Vec::new();
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in &self.categories {
if !groups.contains(c) {
if !index.contains_key(c.as_str()) && groups.len() < max_groups {
index.insert(c, groups.len());
groups.push(c.clone());
}
}
if groups.is_empty() {
groups.push(String::new());
index.insert("", 0);
}
// A value past the categories belongs to the unnamed group, when there is one.
let past_end = index.get("").copied();
let stats = self
.series
.iter()
.map(|s| {
groups
.iter()
.map(|g| {
let pts: Vec<f64> = s
.values
.iter()
.enumerate()
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
.filter_map(|(_, v)| *v)
.filter(|v| v.is_finite())
.collect();
box_stats_by(pts, self.inclusive_quartiles)
})
.collect()
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
for (i, v) in s.values.iter().enumerate() {
let Some(v) = v.filter(|v| v.is_finite()) else { continue };
let g = match self.categories.get(i) {
Some(c) => index.get(c.as_str()).copied(),
None => past_end,
};
if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
b.push(v);
}
}
buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
})
.collect();
(groups, stats)
}
}
/// Most series x group summaries [`Chart::box_groups`] makes.
pub const MAX_BOX_CELLS: usize = 1_000_000;
/// Widens the (min, max) extent `ext` to include `v`.
fn widen(ext: &mut (f64, f64), v: f64) {
ext.0 = ext.0.min(v);
@@ -1454,33 +1472,41 @@ impl Chart {
let n = self.categories.len().max(s.values.len());
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
// Each leaf's path from the top level down, blanks filled from the row above.
let mut last: Vec<String> = vec![String::new(); levels.len()];
let paths: Vec<Vec<String>> = (0..n)
// Labels are shared, not copied: an outer label given once stands for every leaf under it.
use std::rc::Rc;
let empty: Rc<str> = Rc::from("");
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
let paths: Vec<Vec<Rc<str>>> = (0..n)
.map(|i| {
let mut path = Vec::with_capacity(levels.len() + 1);
for (d, lv) in levels.iter().enumerate().rev() {
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
if !l.is_empty() {
last[d] = l.to_string();
last[d] = Rc::from(l);
// A new outer label starts fresh inner groups.
for inner in last.iter_mut().take(d) {
inner.clear();
*inner = empty.clone();
}
}
path.push(last[d].clone());
}
path.push(self.categories.get(i).cloned().unwrap_or_default());
path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
path
})
.collect();
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
let mut out: Vec<TreeNode> = Vec::new();
let mut i = 0;
while i < rows.len() {
let (idx, path) = rows[i];
if path.len() <= 1 {
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
out.push(TreeNode {
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
value: value(idx),
leaf: Some(idx),
children: vec![],
});
i += 1;
continue;
}
@@ -1489,14 +1515,14 @@ impl Chart {
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
j += 1;
}
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let children = build(&sub, value);
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
i = j;
}
out
}
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
build(&rows, &value)
}
}
@@ -1607,6 +1633,40 @@ mod chartex_tests {
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
}
#[test]
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
// 50,000 distinct categories in one series: grouped through a map, one pass.
let n = 50_000;
let mut c = Chart::sample(ChartType::BoxWhisker);
c.series.truncate(1);
c.categories = (0..n).map(|i| format!("g{i}")).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
let (groups, stats) = c.box_groups();
assert_eq!((groups.len(), stats[0].len()), (n, n));
assert!(stats[0].iter().all(Option::is_some));
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
c.series = vec![c.series[0].clone(); 2000];
let (groups, stats) = c.box_groups();
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
assert!(stats.iter().all(|s| s.len() == groups.len()));
// One long outer label over 50,000 leaves is shared, not copied per leaf.
let mut t = Chart::sample(ChartType::Treemap);
t.series.truncate(1);
t.categories = (0..n).map(|i| format!("c{i}")).collect();
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
let mut outer = vec![String::new(); n];
outer[0] = "o".repeat(1 << 16);
t.category_levels = vec![outer];
let tree = t.category_tree();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), n);
// Stacked sums over many sparse series.
let mut st = Chart::sample(ChartType::StackedBar);
st.categories = (0..n).map(|i| i.to_string()).collect();
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
}
#[test]
fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker);

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