Compare commits

...
Author SHA1 Message Date
Dylan M. TaylorandClaude Sonnet 5.5 3aa190edf4 refactor(av1): split inverse transform into stages and passes (S3776)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:51:53 +00:00
Dylan M. Taylor c955407373 security(engine): check regular file and size bound on read, preserve permissions and symlinks on save (#1265)
* security(engine): check regular file and size bound on read, preserve permissions and symlinks on save

* style: format recovery.rs write_atomic
2026-10-24 04:45:48 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 54962c6389 refactor: explicit imports for table modules (SonarCloud S2208) (#1262)
* refactor: replace wildcard table imports with explicit names (SonarCloud S2208)

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

* style: rustfmt

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:43:36 -04:00
Dylan M. Taylor 6189fa8bf6 perf(engine): bound HTML entity semicolon scan window and formatting stack search (#1267) 2026-10-24 04:41:43 -04:00
Dylan M. Taylor 713f605566 perf(chart): optimize axis plotted extent scan and cache non-bar error bar stats (#1266) 2026-10-24 04:41:23 -04:00
Dylan M. Taylor 9641817a15 security: clamp alpha_num bullet expansion and sanitize filename logging (#1264) 2026-10-24 04:40:29 -04:00
Dylan M. Taylor 1e097e3edd security: use exclusive temporary directories for macOS cert import and media fixtures (#1263) 2026-10-24 04:40:26 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 d94790d97a refactor: replace redundant closures with method paths (SonarCloud S1612) (#1261)
Skipped where the receiver type is generic or not evident without a compiler (wrapping_neg, total_cmp, exists, to_vec, as_ref, split_first, Option::into_inner).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:27:07 -04:00
5fadda5ecf chore: resolve SonarCloud maintainability issues (#1255)
* chore: resolve SonarCloud maintainability issues

* fix: address shellcheck SC2059 and fix generic cast ambiguity

* style: apply rustfmt formatting

* fix: restore compile and behaviour in Sonar refactors

- fonts: char::is_alphanumeric takes &self, so filter needs a closure
- h264: keep the mvd accumulator as i32 (prefix is usize)
- model: annotate the non-stacked range accumulators as f64
- engine: chart.options still clears the raw chart XML for upDownBars
  and quartiles; smartart goes back to the glob import (cmd! macro)
- web: keep the original viewport meta (behaviour change)

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

* Assign positional parameters to locals in sha256 verification

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2026-10-23 23:28:09 -04:00
aiongg b913c4f9d0 PDF: pin craft-pdf and krilla to craft-libs main (#1253)
* PDF: pin craft-pdf and krilla to craft-libs main (PNG decode bound)

* PDF: pin craft-pdf and krilla to craft-libs at 9f75d3dc (#254)
2026-10-23 22:37:28 -04:00
aiongg 9aef21e627 Media: a clip opened from a .pptx gets its length and size from its headers (#1241) (#1250) 2026-10-23 19:56:24 -04:00
aiongg 7626f74b99 Media: use craft-media for bitstream, isobmff and matroska (#1034)
* Media: use craft-media for bitstream, isobmff and matroska

deckcraft-bitstream, deckcraft-isobmff and deckcraft-matroska re-export
craft_media::{bitstream, isobmff, matroska} from craft-libs. The packages
and their public paths stay, so the codec crates build unchanged.
deckcraft-bitstream keeps the decoder memory budget as its own module
(budget). deckcraft-media names the new CodecConfig::Apv entry.

* Media: pin craft-media to storytold/craft-libs at f97c84d2 (#245)

* Media: pin craft-media to storytold/craft-libs at f0ad6c1e (#246)
2026-10-23 19:29:43 -04:00
Brandon ThomasandClaude Opus 5.5 a58bdbc756 Docs: Apply to All reaches the slide masters (#891); keep gaps.md a list of shortfalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:28:02 -04:00
Brandon Thomas 10d911cdd6 Merge pull request #1037 from FabPei/agent/issue-955 2026-10-23 17:22:14 -04:00
Brandon Thomas 7871ff3d62 Merge pull request #1076 from fchinu/format_text 2026-10-23 17:22:14 -04:00
Brandon Thomas 8b912c106e Merge pull request #1002 from Silvertree2010/fix/h2821-find-replace-ignores-line-breaks 2026-10-23 17:22:13 -04:00
Brandon Thomas 37ac38447d Merge pull request #896 from Bryson14/feat/symbol-font-mac-remap 2026-10-23 17:22:13 -04:00
Brandon Thomas a7876aab54 Merge pull request #1097 from Silvertree2010/fix/h1120-text-set-keeps-a-stray-carriage 2026-10-23 17:22:12 -04:00
Brandon Thomas e282b90185 Merge pull request #1191 from MarekSpodBiedry/issue-1130 2026-10-23 17:22:12 -04:00
Brandon ThomasandClaude Opus 5.5 4ada0efac2 Tests: Find and Replace across a line break through Session::execute
Covers find offsets, no match spanning the break, replace and undo
(CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:22:11 -04:00
Brandon ThomasandClaude Opus 5.5 8c55529df9 Format the Symbol table tests (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:21:03 -04:00
Brandon Thomas 137685b098 Merge pull request #986 from HyakoV3/fix/974-modern-comment-namespaces 2026-10-23 17:13:40 -04:00
Brandon ThomasandClaude Opus 5.5 07cbe5c908 README: Alpine's AMD Vulkan driver is mesa-vulkan-ati; -wlr has no file chooser
CodeRabbit review notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:32 -04:00
Brandon ThomasandClaude Opus 5.5 a5ca7d04b0 Format Painter: walk only the paragraphs a text selection has
An unclamped hostile selection end (text.select caret [1e18, 0]) made
covered_paragraphs iterate for effectively forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:32 -04:00
Brandon ThomasandClaude Opus 5.5 b6bd10e0a8 text.set: a lone CR is a paragraph break too
Matches edit::insert, which already maps a bare \r to \n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon ThomasandClaude Opus 5.5 c7a1423105 Presenter View: closing its window also closes its slide grid
Otherwise presenter_grid stayed true and held auto-advance and rehearsal for the
rest of the show (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon ThomasandClaude Opus 5.5 e294a09664 Text: apply the Symbol encoding table only to the Symbol face
A Wingdings or Webdings face without private-use coverage no longer gets
Symbol's mapping (0xD8 drew as ¬).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon Thomas 3545a0a995 Merge main (integration tests moved into tests/it) 2026-10-23 17:12:52 -04:00
Brandon ThomasandClaude Opus 5.5 9861de3c29 Media: a negative (padding) video crop keeps the letterboxed frame
The format repair clamps a crop to 0..1, but an unrepaired negative value still
took the stretch path in paint_frame (CodeRabbit on #1008).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:11:34 -04:00
Brandon Thomas 48066c4674 Merge pull request #1008 from MarekSpodBiedry/issue-972
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-23 17:11:34 -04:00
Brandon Thomas 964d470122 Merge pull request #1091 from MMexicann/fix/empty-number-format-sections
# Conflicts:
#	crates/model/src/numfmt.rs
2026-10-23 17:11:13 -04:00
Brandon Thomas 8f81cdc380 Merge pull request #1026 from Silvertree2010/fix/1020-bug-report-deckcraft-gifs-stop-p
# Conflicts:
#	crates/ui-egui/src/media.rs
2026-10-23 17:11:03 -04:00
Brandon Thomas a64487ca2c Merge pull request #1148 from Silvertree2010/fix/h1231-trendline-equation-label-shows-y 2026-10-23 17:10:54 -04:00
Brandon Thomas 5018f53391 Merge pull request #1099 from Silvertree2010/fix/h1167-scientific-number-format-prints 2026-10-23 17:10:54 -04:00
Brandon Thomas bfb1362cef Merge pull request #1054 from Silvertree2010/fix/h738-histogram-with-a-fixed-bin-count 2026-10-23 17:10:53 -04:00
Brandon Thomas 7ada390639 Merge pull request #1035 from Silvertree2010/fix/h601-general-number-format-writes-lar 2026-10-23 17:10:53 -04:00
Brandon Thomas e452a751c1 Merge pull request #1036 from Silvertree2010/fix/h639-number-format-thousands-scaling 2026-10-23 17:10:52 -04:00
Brandon Thomas c6c86e7a3f Merge pull request #1085 from Silvertree2010/fix/1083-rounded-corners-do-not-scale-cor 2026-10-23 17:10:43 -04:00
Brandon Thomas e27d549be7 Merge pull request #1134 from pick2510/fix/3d-in-scaled-groups 2026-10-23 17:10:42 -04:00
Brandon Thomas a0aca7f36b Merge pull request #1187 from samirhvbr/fix/group-shape-appearance 2026-10-23 17:10:42 -04:00
Brandon Thomas b2017dcb47 Merge pull request #1233 from marcoarnulfo/fix/set-transparent-color-pick 2026-10-23 17:10:41 -04:00
Brandon Thomas 4030f3c008 Merge pull request #1234 from marcoarnulfo/feat/last-fill-outline-color 2026-10-23 17:10:41 -04:00
Brandon Thomas a7c4894471 Merge pull request #1239 from marcoarnulfo/feat/canvas-scrollbars 2026-10-23 17:10:40 -04:00
Brandon Thomas 1d6205d576 Merge pull request #1240 from leobasilio/fix/textbox-glow-halo 2026-10-23 17:10:39 -04:00
Brandon Thomas 274a55e812 Merge pull request #1101 from Silvertree2010/fix/1094-partial-circle-preset-missing-ex 2026-10-23 17:10:31 -04:00
Brandon Thomas 7e5b21b794 Merge pull request #1136 from pick2510/fix/spdef-anchor-new-shapes-only 2026-10-23 17:10:31 -04:00
Brandon Thomas fe9674297a Merge pull request #1160 from Hamza0101/test/thumb-render-counter 2026-10-23 17:10:30 -04:00
Brandon Thomas 836313560e Merge pull request #1193 from MarekSpodBiedry/issue-1178 2026-10-23 17:10:30 -04:00
Brandon Thomas b339ba490d Merge pull request #1027 from Silvertree2010/fix/h261-rgba-from-hex-accepts-signs-insi 2026-10-23 17:10:14 -04:00
Brandon Thomas 1763368843 Merge pull request #1056 from oracola91/fix/package-licence-files 2026-10-23 17:10:13 -04:00
Brandon Thomas 2736193d31 Merge pull request #1039 from Hamza0101/control-test-slots 2026-10-23 17:10:12 -04:00
Brandon Thomas e6527e5c2c Merge pull request #1060 from oracola91/fix/control-accept-backoff 2026-10-23 17:10:12 -04:00
Brandon Thomas 2d7b69d7b6 Merge pull request #1199 from marcoarnulfo/feat/anim-play-from 2026-10-23 17:10:12 -04:00
Brandon Thomas 8485b10356 Merge pull request #1207 from xc4pr/fix/1201-ideas-band-reset 2026-10-23 17:10:11 -04:00
Brandon Thomas e8e36be177 Merge pull request #894 from kk376/fix/apply-to-all-master-background 2026-10-23 17:09:50 -04:00
Brandon Thomas 0f00a94c23 Merge pull request #906 from MarekSpodBiedry/issue-843 2026-10-23 17:09:50 -04:00
Brandon Thomas 5bebcd9075 Merge pull request #872 from kinower/fix/responsive-mouse-wheel-navigation 2026-10-23 17:09:49 -04:00
Brandon Thomas 998429fb9e Merge pull request #820 from Silvertree2010/fix/h1617-paste-from-html-a-newline-inside 2026-10-23 17:09:49 -04:00
Brandon Thomas f5e9cf5b0a Merge pull request #999 from Faabyy/fix/media-clip-per-slide-994 2026-10-23 17:09:41 -04:00
Brandon Thomas 9057bb7ef0 Merge pull request #1009 from MarekSpodBiedry/issue-1005 2026-10-23 17:09:40 -04:00
Brandon Thomas 6498809e29 Merge pull request #1161 from kbarni/faster-transition 2026-10-23 17:09:40 -04:00
Brandon Thomas eefb1ac380 Merge pull request #1211 from marcoarnulfo/fix/presenter-notes-bullets 2026-10-23 17:09:39 -04:00
Brandon Thomas ddeef2d9a6 Merge pull request #1228 from marcoarnulfo/feat/show-start-from 2026-10-23 17:09:39 -04:00
Brandon Thomas 1cb436bcbb Merge pull request #1235 from MarekSpodBiedry/issue-1229 2026-10-23 17:09:38 -04:00
Brandon Thomas e128a09caa Merge pull request #988 from Zetenypapp/fix/smart-duplicate-966
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-23 17:09:38 -04:00
Brandon Thomas 3084f5b5f4 Merge pull request #989 from Zetenypapp/fix/find-focus-951 2026-10-23 17:09:37 -04:00
Brandon Thomas 532d88cda1 Merge pull request #1003 from Faabyy/fix/resize-snaps-dragged-edge-961 2026-10-23 17:09:37 -04:00
Brandon Thomas e676b823f9 Merge pull request #1007 from MarekSpodBiedry/issue-971 2026-10-23 17:09:36 -04:00
Brandon Thomas 7d80f052fb Merge pull request #1064 from Silvertree2010/fix/h848-xml-text-escaping-writes-carriag 2026-10-23 17:09:24 -04:00
Brandon Thomas 86f6b365f3 Merge pull request #1145 from pick2510/fix/table-text-other-style 2026-10-23 17:09:24 -04:00
Dylan M. Taylor 8a1a1ee698 Merge pull request #1243 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:59:21 -04:00
Dylan M. Taylor 4b6ff7d324 ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:51:48 -04:00
LeonardoandClaude Sonnet 5.5 8aa76142c9 Render: a shape with no fill and no line draws no glow round its box
A text box with a glow in its shape properties but no fill and no line got a
blurred ring round the whole box; PowerPoint shows only the text's own glow.
The glow of a frame with a line, and of a filled shape, is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:06:47 -03:00
Marco Arnulfo 070cee617e Set Transparent Color: take the pixel of the topmost selected picture, not the first one selected 2026-10-23 21:17:27 +02:00
aiongg c787f4f080 Packaging: pin the signing client and appimagetool, and check their hashes (#1082)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Fail clearly when no appimagetool is pinned for the architecture

* Pin the AppImage runtime
2026-10-23 13:40:57 -04:00
xc4pr ebf67f8a9a Design Ideas: switching ideas also removes a band saved by earlier versions (#1201) 2026-10-23 19:26:08 +02:00
Marco Arnulfo b7ea777be7 Set Transparent Color: map the click through the picture's 3-D camera and find grouped pictures that keep no box of their own 2026-10-23 19:16:09 +02:00
Marco Arnulfo 8a75773988 Canvas: only the primary button drags a scroll bar thumb, the grab ends with the drag and follows a zoom mid-drag (#1157) 2026-10-23 19:12:35 +02:00
xc4pr 8a61586585 Design Ideas: store the Full-bleed band's opacity as PPTX does, so switching ideas still removes it after a save (#1201) 2026-10-23 19:08:09 +02:00
Marco Arnulfo 9a3db3840a Canvas: a scroll bar press decides thumb or track where it went down, and the thumb follows the grab point (#1157) 2026-10-23 19:04:30 +02:00
aiongg 5bdea79f5c CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#1214)
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 12:59:34 -04:00
aiongg f001f37906 CI: one test binary per crate, nextest, line tables only (#1213)
* Build: line tables only for workspace crates, no debug info for dependencies

* Tests: one integration-test binary per crate

* xtask ci: nextest, doctests, test build without examples, one wasm check
2026-10-23 12:43:34 -04:00
xc4pr 49f296cca1 Design Ideas: Full-bleed puts the band on the visible title, not a hidden one (#1201) 2026-10-23 18:25:46 +02:00
Marco Arnulfo f43d42d535 Canvas: scroll bars when the zoomed slide is larger than the window (fixes #1157) 2026-10-23 18:21:28 +02:00
MarekSpodBiedry ac0130d2d7 Keep the slide show in a window for "Browsed by an individual" (#1229) 2026-10-23 17:31:00 +02:00
Dylan M. Taylor 0f4fffcf6c Use chrono instead of hand-rolled UTC conversion (#1116)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / civil copies and the hand-written
weekday / leap-year / days-in-month helpers with chrono. chrono is already a
workspace dependency (used by crates/text), so this reuses that entry and just
adds `chrono = { workspace = true }` to the engine and desktop app rather than
a second version.

- apps/deckcraft logging `timestamp` now formats through chrono (private
  civil_from_days deleted).
- engine cmd::design `civil` (days since 1970 -> date, used for comment
  timestamps in review.rs) uses chrono::NaiveDate.
- text datetime `DateTime::is_valid` / `weekday` use chrono::NaiveDate instead
  of a hand-rolled leap-year table and days-since-1970 weekday.

Output is byte-identical and pinned by the existing logging, design and
datetime-field tests; each converted helper keeps a proleptic-Gregorian
fallback for dates outside chrono's NaiveDate range.

Follow-up to storytold/cadcraft#1785

* text: make chrono available on wasm32

is_valid/weekday in crates/text/src/datetime.rs use chrono unconditionally,
but chrono was only declared under the non-wasm target, so the crate (and
engine/pdf/render/ui-egui depending on it) failed the wasm32-unknown-unknown
check in `cargo xtask ci`.

- crates/text: declare chrono unconditionally (wasm-safe `std` baseline),
  and keep `clock` in a separate cfg(not(target_arch="wasm32")) entry for
  now_local()'s local clock, which is already wasm-gated to return None.
- workspace: drop `clock` from the shared chrono entry so it is no longer
  forced onto wasm builds (engine uses chrono unconditionally too); `clock`
  is now requested per-crate only where the local clock is read.

* Never panic on a clock outside chrono's range; harden the civil fallback

logging.rs timestamp(): chrono's From<SystemTime> unwraps, so a clock past
year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read an out-of-range or pre-epoch clock as the epoch, as the old saturating
code did. Years past 9999 are written with {:04} like before, because %Y adds a
'+'. Tests cover both cases.

cmd/design.rs civil_fallback(): make the hand-rolled fallback's year arithmetic
saturating so civil() cannot overflow or panic at i64::MAX/MIN (the engine rule
forbids crashes and unchecked arithmetic). Behaviour is unchanged for every real
input; a test exercises both extremes. The output year is formatted with {y:04}
by review.rs, so no %Y prefix issue.

crates/text/src/datetime.rs: checked after review; is_valid already limited the
year to 1..=9999 before this PR, weekday() is only reached for valid dates, and
all its arithmetic is checked/euclidean, so no change is needed.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Make civil() exact for every i64 instead of saturating

The out-of-range fallback saturated z + 719_468, so civil(i64::MAX)
returned a date 719,468 days early. civil() now has chrono convert the day
within its 400-year Gregorian cycle and adds whole cycles back as years
(i128 for the extremes), so the hand-rolled fallback is gone. Exact results
for i64::MAX/MIN and both sides of chrono's range are tested against Hinnant's formula.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/deckcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:20:42 -04:00
Dylan M. Taylor bdcb532608 packaging: [[ ]] tests, named positional parameters, explicit returns and a default case (SonarCloud S7688, S7679, S7682, S131) (#1212)
Use [[ ]] for conditional tests in the bash packaging scripts, give positional
parameters a name before use, end the shared helpers in common.sh with an explicit
return (bare return keeps the last command's status), and give the FreeBSD arch
case a no-op default. No behaviour change.
2026-10-23 11:16:44 -04:00
Marco Arnulfo de18659978 Pictures: Set Transparent Color refuses a spot that is already transparent (#1217) 2026-10-23 17:14:51 +02:00
Marco Arnulfo 58d746bc17 Shape Outline button sets only the colour; docs row under Revision history; tests anchor on the hex field's label and check the scheme slots 2026-10-23 17:07:21 +02:00
Marco Arnulfo 9eacd994bb Pictures: Set Transparent Color picks the colour by clicking the picture, as in PowerPoint (fixes #1217) 2026-10-23 17:06:58 +02:00
Marco Arnulfo 40f6f1756b Shape Fill and Shape Outline: the button applies the last colour picked from its menu (part of #1080) 2026-10-23 17:01:26 +02:00
MarekSpodBiedry 3a917bb4e0 Keep each window's Esc, G and auto-advance to its own All slides grid (#1130) 2026-10-23 16:08:15 +02:00
Marco Arnulfo 90d40e2ecc Show: refuse a show.start from that is not a slide
show.start {from} turned a negative or fractional index into slide 0 and an index past the end into a show that opened on the end screen, over the command and the control channel alike. Both now refuse it, as the slide commands do since #1063; a hidden slide still plays.
2026-10-23 14:48:45 +02:00
Marco Arnulfo da9bbac5a0 Play From: skip triggered effects, reject an invalid from
- The pane picks the first effect of the selected shape that is on the click sequence, so a
  triggered effect before it no longer turns the button back into Play All; a clicked triggered
  row falls back the same way.
- animation.preview rejects a from that is not an effect index instead of playing all.
- target-app-parity: the Animation Pane timeline is no longer a shortfall.
2026-10-23 10:22:51 +02:00
fchinuandClaude Sonnet 5.5 da3e89695f Format Painter: paint a drag release only if the drag changed the selection
A Shift-press at the caret inside the edited text starts a text drag
without changing the selection; releasing it no longer repaints the
existing range.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:34:39 +02:00
fchinuandClaude Sonnet 5.5 6e201094c6 Format Painter: compare the whole text target when a click selects text
A double or triple click that moves the selection to another shape or
cell at the same offsets now counts as a change and paints.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:20:40 +02:00
Marco Arnulfo c0b405c42a Presenter View: speaker notes keep their bullets, numbers and formatting (fixes #1128) 2026-10-23 03:54:04 +02:00
xc4pr ced5dc04ed Design Ideas: recognise the band by its exact fill, not a saved tag (#1201) 2026-10-23 02:41:46 +02:00
xc4pr f330a02970 Design Ideas: mark the Full-bleed band so a fill the user set is kept (#1201) 2026-10-23 02:19:55 +02:00
xc4pr 7858d589cd Design Ideas: switching ideas removes the Full-bleed title band (#1201) 2026-10-23 00:45:54 +02:00
Marco Arnulfo 19ebd9c2dc Animation Pane: Play From the selected effect
With an effect selected the pane's play button reads Play From and the
canvas preview starts at that effect on the play-all clock, so the
effects before it have already played. With nothing selected it is
still Play All. animation.preview takes an optional from index.
2026-10-22 23:03:35 +02:00
Brandon ThomasandClaude Opus 5.5 b47195feec Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:20 -04:00
MarekSpodBiedry bde35fab11 Show All slides in the Presenter View window, not on the audience screen (#1130) 2026-10-22 21:10:16 +02:00
MarekSpodBiedry c4a459d1ca Cap the PDF slide raster by total pixel count (#1178) 2026-10-22 21:03:42 +02:00
Samir Hanna Verza 07c8e2d77f M3: apply fill and outline edits to group members (#1079) 2026-10-22 13:20:11 -03:00
Keresztes BarnaandClaude Opus 5.5 df0fab857d show: keep the document a slide backdrop was rendered from
The backdrop cache knew its document by address only: after an edit
during the show, a new copy could reuse the freed address and get the
old backdrop. The cache now holds that document and matches on it.
(CodeRabbit review on #1161.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:59:53 +02:00
Hamza 42e89b347d Thumbnails: count the renders, not only the thumbnails held (#969)
Follow-up to #1011, as asked on #1038: a counter of thumbnails actually
rendered, and tests that use it.

`Textures::thumb_count` is the number of thumbnails *held*. It settles at the
rows in view whether the pane rendered them once or a thousand times, so it
cannot see a pane that renders the same rows over and over -- which is what
#969 was: the flashing white was the rendering, not the holding.

`Textures::thumb_renders` counts the renders, and a settled pane is one that
has stopped asking for them.

The two new tests assert what the held count cannot:

- every thumbnail held was rendered, and not many more than that were rendered
  and thrown away (measured: 8 held, 8 rendered at rest);
- ten more frames of the same view render nothing at all;
- after a 40,000 pt scroll of an 800-slide deck, 24 renders in total -- the
  rows it landed on and the ones it passed over, not the deck -- and then it
  settles again.

With the cache made to thrash (its 600-entry limit cut to 4, so the rows in
view are evicted and re-rendered every frame), the merged test for the Slide
Sorter still passes -- the held count settles at 4 -- while this one fails with
"255 renders for 4 thumbnails". That is the hole the counter closes.
2026-10-22 19:20:07 +05:00
skylar taylor-barrick a2435383b1 Merge pull request #996 from Silvertree2010/fix/h2800-deleting-a-shape-that-is-an-anim
Clear animation triggers that point at deleted shapes
2026-10-22 07:50:16 -06:00
skylar taylor-barrick d01254cf2d Merge pull request #1071 from Silvertree2010/fix/h923-opc-target-resolution-cuts-a-par
Strip URI fragment before percent-decoding OPC targets
2026-10-22 07:50:09 -06:00
skylar taylor-barrick 3f399dd450 Merge pull request #1063 from Silvertree2010/fix/h886-slide-delete-with-an-invalid-neg
Reject invalid slide index params instead of using the selection
2026-10-22 07:50:01 -06:00
skylar taylor-barrick 8f04a999b9 Merge pull request #790 from Hamza0101/fix/empty-line-height
Text: an empty line takes the size of the mark that ends it
2026-10-22 07:49:55 -06:00
skylar taylor-barrick f011313a3a Merge pull request #1011 from Faabyy/fix/thumbs-visible-only-969
Thumbnails: only render the slides in view (#969)
2026-10-22 07:49:48 -06:00
skylar taylor-barrick 792829ec41 Merge pull request #982 from alexeyvolkoff/pr-fix-wasm-startup
fix(wasm): avoid std services that panic on wasm32-unknown-unknown at…
2026-10-22 07:49:40 -06:00
BFlat 4a1d27a227 Merge pull request #1158 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:23 +07:00
bflatastic 0b8a4cc23e README: the app icon above the title 2026-10-22 17:57:31 +07:00
Keresztes BarnaandClaude Opus 5.5 31a828b027 show: keep the slide backdrop while animations play
Each animation frame re-rendered the whole slide, backdrop included. A
full-slide layout picture with an 18 pt glow cost ~345 ms of the ~390 ms
frame (2-3 fps at 1080p). Frames now draw only the slide's shapes over
the backdrop, which is rendered once, ahead of time while the slide
rests: ~28 ms a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:11:09 +02:00
NetterSilverandClaude Opus 5.5 1dd96d3ddc Use a relative coefficient threshold in trendline equations
Found while testing crates/model/src/trendline.rs:~507 (poly_equation)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:03:48 +02:00
kinowerandClaude Opus 5.5 dffef33960 UI: drop the wheel carry whenever another presentation is drawn
The presentation check on the leftover wheel fraction ran only while the
canvas was hovered and fitted, so switching to another presentation from
the tabs and back kept the old fraction. It now runs every frame the canvas
draws a presentation, before the hover and overflow branches.

Test: 0.75 of a line in one presentation, a switch away and back with the
pointer off the canvas, then 0.25 does not turn the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:52:50 +02:00
Dominik StrebelandClaude Opus 5.5 7f2955be1c Text: a table in a content placeholder keeps the other-text style
A table can sit in a content placeholder (its graphicFrame carries
<p:ph idx="1"/>). PowerPoint for Mac still draws its text in the master's
other-text style: in a synthetic deck with a bulleted 36 pt bodyStyle, a
14 pt otherStyle and a 26 pt defaultTextStyle, a table in the placeholder
and one outside it both draw 14 pt cells with no bullet or body indent,
also when the layout's and master's body placeholders carry 30 and 32 pt
list styles.

master_list_style now checks for a table before a placeholder, and
level_style takes nothing from placeholder parents for a table, so the
table shape itself (its formatting state) resolves as its cells do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:10:13 +02:00
Dominik StrebelandClaude Opus 5.5 aa6f45ba2c Text: table text takes the master's other-text style
PowerPoint for Mac lays out a table's text in the master's other-text
style (p:otherStyle), while shapes and text boxes take the presentation's
default text style (p:defaultTextStyle). A synthetic deck with an
otherStyle of 14 pt and a defaultTextStyle of 26 pt, exported as PDF,
draws a table cell at 14 pt and a shape's and a text box's text at 26 pt.
DeckCraft laid cells out through a stand-in shape that wasn't a table, so
they took the default text style: a deck whose two styles differ (18 and
20 pt) drew its table text 2 pt too large and wrapped its cells.

master_list_style sends text in a non-placeholder table to otherStyle,
and Shape::table_cell builds the stand-in every cell layout uses (drawing,
row heights, text placement, the caret and selection in a cell) as a
table. The table shape itself resolves the same way, so the formatting
state of a selected table reports the size its cells draw at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:49:42 +02:00
kinowerandClaude Opus 5.5 e11c7e4d7e UI: reset the fractional wheel carry when the presentation changes
The leftover fraction of a wheel line is now stored with the active
document's uid and dropped when another presentation becomes active, so a
quarter line left in one deck can no longer turn a slide in another.

Test: 0.75 of a line in one presentation followed by 0.25 in a new one
does not turn the page; a whole line in the new one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:09:23 +02:00
Dylan M. Taylor 128cb0b883 Merge pull request #1139 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:49 -04:00
kinowerandClaude Opus 5.5 8ddb977733 UI: accumulate fractional mouse-wheel lines into whole slide steps
High-resolution and free-spin wheels on Windows send fractions of a line
(e.g. 0.125 per event); rounding each one up turned a single notch into
several slides. The fractional line delta now accumulates in ui.data temp
state, resets when the direction changes, and each whole line is one
slide. slide.go is skipped when the target is the current slide.

Test: eight 0.125-line events turn exactly one slide, and a reversal
drops the leftover fraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:01:48 +02:00
kinower 7dce06dca4 UI: keep rapid mouse-wheel slide navigation steps 2026-10-22 00:43:03 +02:00
Dylan M. Taylor e149f32208 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:06:39 -04:00
Dylan M. Taylor 354087ecd5 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:30 -04:00
Dylan M. Taylor 4113361660 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:52 -04:00
Dominik StrebelandClaude Opus 5.5 30d00468e2 PPTX: a theme's spDef anchor is for new shapes, not the file's own
A shape whose text has no anchor of its own (`<a:bodyPr/>`) was given the
anchor of the theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`),
which Office's themes set to `ctr`, so its text was centred in the box.
PowerPoint for Mac draws such text at the top: a synthetic deck with an
spDef anchor="ctr" and three rectangles (`<a:bodyPr/>`, anchor="t",
anchor="ctr"), exported as PDF, puts the first two at the top. The spDef
defaults are for shapes inserted later.

Opening a file now anchors every shape without an anchor at the top, as
the standard has it; the model's middle default is still written as ctr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:05:25 +02:00
Dominik StrebelandClaude Opus 5.5 7919429652 Render: measure 3-D point sizes without the camera's turn
shape_at folds a 3-D camera's turn into the shape's transform, so
per_point() measured its foreshortening as if it were a group's
child-coordinate scale: in a 1:1 group, a shape under a turned camera
got a factor other than 1 and drew its extrusion depth, contour and
bevel at the wrong size. The factor is now measured on the transform
before the turn (m with the camera's affine taken back out), and the
turned transform still draws the geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:02:17 +02:00
Dominik StrebelandClaude Opus 5.5 48dbdffe07 Render: 3-D sizes in a scaled group stay in points
A group's child coordinates scale its shapes' boxes, but extrusion
depths, contour widths and bevel sizes are points, as PowerPoint draws
them. The renderer applied them in the child space, so the group's scale
multiplied them: a 1 pt contour on a small extruded cube in a legacy
group (1360 child units across a 61 pt group) was drawn hundreds of
points wide, a band across the whole slide, and the cube's extrusion ran
off towards the vanishing point.

per_point() gives the shape-space units per point (the inverse of the
child-coordinate scale that line_space already separates for line
widths); the extrusion depth, contour width and bevel width and height
are converted with it before drawing. Outside a group it is 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:47:33 +02:00
Dylan M. Taylor 5a76518ddd ci: add SonarCloud scan workflow and config 2026-10-21 13:55:12 -04:00
skylar taylor-barrick 9ad2cf3be8 Merge pull request #432 from Lexx143/fix/macos-finder-open
macOS: open presentations from Finder, Open With and the Dock (#138, #347)
2026-10-21 10:39:36 -06:00
fchinuandClaude Sonnet 5.5 f71ce3fa9d Format Painter: paint paragraph formatting per covered paragraph
Paragraph properties now go only onto paragraphs the selection covers
whole, so partly covered end paragraphs and the paragraph after a
triple-click keep their bullets. Double and triple clicks apply the
painter only when they changed the text selection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 13:46:05 +02:00
Dylan M. Taylor 63831cdfa9 Merge pull request #865 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:14 -04:00
FaabyyandClaude e03debfd4b Media: engine requests resolve on the editor's slide (#994)
The media.* commands look the shape id up on the editor's current slide, so
the request has to be carried out against that slide too. Passing the show's
slide instead could play another slide's clip under a repeated id.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
FaabyyandClaude 906898f662 Canvas: a flipped box snaps the edge under the dragged handle (#961)
The resize handles sit in the box's unflipped frame, so in a flipped box
(also one that a drag past the opposite edge just flipped) the dragged
handle is on the other side. Snapping looked at, and moved, the fixed
edge instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 2741d3236b Thumbnails: only render the slides in view (#969)
The slide thumbnail pane and the Slide Sorter asked the texture cache for a
thumbnail on every row, in view or not, three or four new ones a frame. The
cache keeps at most 600 thumbnails and drops the 200 oldest beyond that, so
on a deck of more than 600 slides the pane evicted its own thumbnails before
it reached the end: it kept rendering, the rows in view went white again and
again, and it never finished.

Now only rows within the scroll area's clip (plus two rows of margin) ask for
a thumbnail and keep the pane repainting; the others are still laid out, so
scroll height, section headers, clicks and drag-to-reorder are unchanged.
Small decks behave as before.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 03fce2a Thumbnails: only render the slides in view (#969)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/ui-egui/src/huge_deck_tests.rs
#	modified:   crates/ui-egui/src/sorter.rs
#	modified:   crates/ui-egui/src/thumbs.rs
#
2026-10-21 06:07:58 +00:00
FaabyyandClaude 96697f7612 Media: the playing clip decides whether it carries over (#994)
The carry-over check looked the clip up again on the previous slide. If
that slide held the same media under the same id without Play Across
Slides, the voice stopped one slide later. A slide without the id fell
back to the first match in the deck. Use the clip the voice was started
with; the next slide is only checked for another clip under that id.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 7b0252f291 Canvas: resizing snaps the edge being dragged (#961)
snap_resize ran snap_rect on the whole box, which looks at all three
edges on each axis and takes the nearest line. A fixed edge already on a
line (distance 0) always won, so the dragged edge never snapped; a fixed
edge near a line moved the dragged edge by its own distance. Resizing now
looks only at the dragged edges (snap_edges), also for the grid fallback.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 92df608 Canvas: resizing snaps the edge being dragged (#961)
# Next command to do (1 remaining command):
#    pick abb0e09 Canvas: a flipped box snaps the edge under the dragged handle (#961)
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/engine/src/tools.rs
#
2026-10-21 06:07:58 +00:00
FaabyyandClaude 2303efe7d5 Media: stop a clip across slides when the next slide reuses its id (#994)
A clip set to play across slides kept its voice even when the next slide
holds a different clip under the same shape id, so that slide's own clip
never started. Keep the voice only when the next slide has no clip under
that id or the same media.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 2d6582163f Media: look a clip up on the slide it belongs to (#994)
The PPTX import numbers shapes per slide, so two slides can hold different
videos under one shape id. find_clip searched the whole deck and took the
first match, so the show's media cues, the keep-across-slides filter and
the media.* requests all got the first slide's clip: from slide 3 on, video 2
played. find_clip now takes the slide the request comes from and searches it
first, then the rest of the deck.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 1ab070b Media: look a clip up on the slide it belongs to (#994)
# Next commands to do (3 remaining commands):
#    pick ad4e3f0 Media: stop a clip across slides when the next slide reuses its id (#994)
#    pick 0957b58 Media: the playing clip decides whether it carries over (#994)
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/ui-egui/src/lib.rs
#	modified:   crates/ui-egui/src/media.rs
#	modified:   crates/ui-egui/src/show.rs
#
2026-10-21 06:07:58 +00:00
NetterSilverandClaude Haiku 5.5 e89a893fe1 Add angle adjust handles to partial-circle presets
Fixes #1094

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:04:04 +00:00
NetterSilverandClaude Haiku 5.5 72a9e4456e Fix scientific number format for subnormal values
Found while testing crates/model/src/numfmt.rs:204-211 (sci)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:34:30 +00:00
NetterSilverandClaude Haiku 5.5 309d9f5b2c Treat CRLF as a single paragraph break in text.set
Found while testing crates/engine/src/cmd/text.rs:1219 (set), via crates/model/src/text.rs:605 (TextBody::from_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:13:45 +00:00
Mex 6b91132ce7 M8: document verified empty-format reliability scope
Record the synthetic formatter regression and preserved General fallback; broader chart gaps and readiness scores remain unchanged.
2026-10-20 23:10:09 +02:00
Mex 5e0a60b15e M8: record empty chart number-format section fix
Record narrow formatter behavior and synthetic regression evidence without changing chart scores or readiness estimates.
2026-10-20 23:09:29 +02:00
Mex 02b3940efd M8: honor explicit empty chart number-format sections
Keep blank whole-format General fallback while hiding values selected by explicit empty sections. Pin section selection, signed zeros, semicolon boundaries and General controls with regressions. Contribution on behalf of @MMexicann.
2026-10-20 23:08:54 +02:00
NetterSilverandClaude Haiku 5.5 98b6d04413 Keep rounded corners round in non-uniformly scaled groups
Fixes #1083

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:50:30 +02:00
Bryson Meiling e5a407d6e2 text: add Symbol euro and Apple alternatives; keep covered bullets
Adobe symbol.txt also maps 0xA0 to the euro sign (U+20AC), which
symbol_unicode lacked. Apple's Symbol maps a few codes to different
Unicode than Adobe (0xA4 to U+2044, 0xE1/0xF1 to U+3008/U+3009), so
symbol_font_draw now tries those alternatives after the Adobe character
when the installed face doesn't cover it. The Adobe aliases for
0x44/0x57/0x6D (U+2206/U+2126/U+00B5) are not Apple alternatives and
stay out.

symbol_font_char masked every bullet character to its low byte before
checking direct coverage, so a 'bullet' (U+2022) bullet drew an
unrelated Symbol code. It now maps only symbol-font codes (low byte or
U+F0xx) and falls back to the original character when the face covers
it.
2026-10-20 12:23:50 -06:00
fchinuandClaude Sonnet 5.5 6f83ed464d Format Painter: apply on text selection, keep bullets on partial lines
The painter only applied when a shape was clicked. It now also applies
when a text range is selected by drag, double-click or triple-click.
Paragraph formatting (bullets, alignment) is copied only onto
paragraphs the selection covers whole.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:46:15 +02:00
NetterSilverandClaude Haiku 5.5 e37267f548 Strip URI fragment before percent-decoding OPC targets
Found while testing crates/pptx/src/opc.rs:283-285 (resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:36:13 +02:00
Brandon ThomasandClaude Opus 5.5 a31b990d32 Skip CodeRabbit reviews on Dependabot PRs (#1068)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:09 -04:00
Dylan M. Taylor 7571e01210 Merge pull request #984 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:33:18 -04:00
NetterSilverandClaude Haiku 5.5 18f5e17b36 Escape carriage return as &#13; in XML text content
Found while testing crates/pptx/src/xml.rs:363 (esc)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:54:55 +00:00
NetterSilverandClaude Haiku 5.5 65564cfc1b Reject invalid slide index params instead of using the selection
Found while testing crates/engine/src/cmd/slide.rs:234 (delete); same pattern in index_of :182, move_slide :269, duplicate :193 and section_add :534

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:29:18 +02:00
oracola91 2071ff599d Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:51 +03:00
oracola91 1df1d2e724 The AppImage and DeckCraft.app ship the licence files
The AppImage step moved usr/share/doc out of the AppDir, so the AppImage
shipped without the licence files the .deb, .rpm and .tar.gz carry from
the same staged tree. DeckCraft.app only got the craft-fonts licences, and
no licence at all when built without CRAFT_FONTS_DIR.

Keep usr/share/doc in the AppDir, and copy the docs into the app bundle
with copy_docs, as the CLI zip does, before signing.
2026-10-20 14:21:42 +03:00
NetterSilverandClaude Haiku 5.5 e843309280 Fix extra empty histogram bin with a fixed bin count
Found while testing crates/model/src/chart.rs:1129-1135 (histogram_bins)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:28:08 +00:00
Brandon ThomasandClaude Opus 5.5 fe510bdac6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:18:44 -04:00
Eduardo GarciaandClaude Sonnet 5.5 a9031d63e1 Test: resolve attribute prefixes in the namespace check (#974)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:12:53 -03:00
Zetenypapp 0dc39b9efc Merge remote-tracking branch 'origin/main' into fix/smart-duplicate-966
# Conflicts:
#	ROADMAP.md
2026-10-19 21:33:38 +02:00
Zetenypapp 57d65faf4f Merge remote-tracking branch 'origin/main' into fix/find-focus-951
# Conflicts:
#	ROADMAP.md
2026-10-19 21:33:38 +02:00
Hamza 7040bb0619 Control server: cap the polite drain at the grace deadline
set_read_timeout applies to each read, so a peer that sent a byte just
before every timeout expired could hold the connection thread, and its
slot, for roughly twice the grace. Set the remaining time before each
read instead.
2026-10-20 00:28:41 +05:00
Hamza 900f62f5a3 Control server: a rejected line's reason survives a pipelined peer
A client that sent another line while its previous one was being rejected never
saw the reason. `serve` writes the error and drops the socket; the client's
second write lands on a socket the server has already closed, the kernel answers
it with RST, and an RST discards what was queued for the client -- including the
reply. The client reads a connection reset instead of being told why.

The comment there, "a peer that can't read the reason loses nothing", holds for
a peer that stopped reading. A peer that is reading loses the reason, and it is
the one that gets reset.

`close_politely` shuts the write side down so the FIN follows the reply, then
reads and discards for up to the write timeout and at most 64 KiB, so the peer's
pipelined line is absorbed instead of answered with RST. Both paths that reject
and close use it: a line that is not a request, and a line over the length
limit. A peer that keeps talking is still dropped at the deadline.

This is also why `a_line_that_is_not_a_request_closes_the_connection` was red
about four times in five: it writes a second line after the junk one. Run alone
twelve times, it failed 10 on `main`, 0 with the second line removed, and 0 with
this change and the second line kept.
2026-10-20 00:28:41 +05:00
Bryson Meiling f8c8223ff0 Merge origin/main into feat/symbol-font-mac-remap 2026-10-19 13:02:38 -06:00
Bryson Meiling 883cc6e391 text: drop ASCII < = > ? remaps and draw them from Symbol directly
0x3C-0x3F are plain ASCII < = > ? in Adobe's symbol encoding (<= >= inf
sit at 0xA3/0xB3/0xA5 and therefore at 0x5C), so 'a<b' set in Symbol
drew 'a<=b' on macOS. Drop those four symbol_unicode arms and their
pairs in the whole-table test, and list 0x3C-0x3F as ASCII.

symbol_font_draw returned None for codes with no table entry, so U+F031
('1'), U+F028 ('(') etc. still didn't draw from Symbol. For 0x20-0x7E
with no table entry, fall back to the ASCII character itself when the
face covers it; covers_symbol now delegates to symbol_font_draw so the
shaping path picks the Symbol face for those codes too.

ROADMAP header keeps a single Last reviewed line (replaces, not adds).
2026-10-19 12:58:20 -06:00
Mexandskylar taylor-barrick f34f4121c8 Format: retain imported chart parts through native saves (#851)
* M2.1: preserve chart parts through native saves

Serialize retained chart relationships and payloads in native files.
Add native and headless file-command round-trip regressions.

Contribution on behalf of @MMexicann.

* Format: bound opaque chart parts as native ZIP entries

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Model: track transient native chart part entries

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* PPTX: initialize native chart entry metadata

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Engine: adapt chart native roundtrip fixtures

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Docs: describe bounded chart sidecars and validation

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Roadmap: record bounded chart persistence evidence

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

---------

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-19 11:44:16 -06:00
Fabian ba194e35d8 fix: DeckCraft upstream #955
README: the Linux runtime libraries a tarball or musl install has to provide, with the Alpine and Void package names and what each one is for.
2026-10-19 16:57:48 +02:00
NetterSilverandClaude Haiku 5.5 1090ca2d70 Apply trailing-comma thousands scaling to formats with decimals
Found while testing crates/model/src/numfmt.rs:256 (fixed)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:55:10 +00:00
NetterSilverandClaude Haiku 5.5 9412d3ccaf Format General scientific notation like Excel (1.5E+12)
Found while testing crates/model/src/numfmt.rs:115-116 (general)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:09:27 +00:00
NetterSilverandClaude Haiku 5.5 c90ea7d9ae Reject non-hex characters such as '+' in Rgba::from_hex
Found while testing crates/color/src/lib.rs:41

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 05:34:48 +00:00
NetterSilverandClaude Haiku 5.5 5beca8c1b6 Play animated GIFs inside groups
Fixes #1020

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 05:21:55 +00:00
MarekSpodBiedry 5ac14f2015 Test: modify the slide through Arc::make_mut (#1005) 2026-10-19 00:44:03 +02:00
MarekSpodBiedry 9b56f83929 Show: draw shapes stacked above a playing video over its frame (#1005) 2026-10-19 00:37:51 +02:00
MarekSpodBiedry 935724850b Insert: keep the Icons popup open when clicking the search box (#971) 2026-10-18 23:39:50 +02:00
MarekSpodBiedry a49570e45b Keep the crop of a PPTX video and apply it in the poster and playback (#972) 2026-10-18 23:38:11 +02:00
NetterSilverandClaude Haiku 5.5 b75ea712e9 Count line breaks as one char in Find/Replace
Found while testing crates/engine/src/cmd/find.rs:107-121 (para_chars / para_matches; splice at :124)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:53:53 +02:00
Dylan M. Taylor fc8e811c2a docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:35 -04:00
Dylan M. Taylor 010941ec60 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:16 -06:00
NetterSilverandClaude Haiku 5.5 e34169ff75 Clear animation triggers that point at deleted shapes
Found while testing crates/engine/src/cmd/edit.rs:573-585 (delete)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:16:01 +02:00
ZetenypappandClaude Opus 5.5 86e7879f5e Duplicate: Undo and Redo forget the last Duplicate's move
A shape added after an Undo can get an undone copy's id; duplicating it then
used the move measured from the old original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:52:41 +02:00
Kushagra Kumar 0a0ad04f47 fix(design): ensure Apply to All clears layout overrides when slides and masters match 2026-10-18 23:11:24 +05:30
Kushagra Kumar 74bc27f27e fix(design): preserve master and layout backgrounds on reset and copy (#891) 2026-10-18 22:28:15 +05:30
MarekSpodBiedry 445e7c6b55 About credits test: clear textures_delta, drive the real dialog tabs; ROADMAP progress-log row for #843 2026-10-18 18:39:06 +02:00
MarekSpodBiedry 0298201b2c Merge remote-tracking branch 'origin/main' into review-906 2026-10-18 18:37:41 +02:00
NetterSilverandClaude Haiku 5.5 03993057a4 fix: decode &nbsp; to a non-breaking space so blank paragraphs survive trimming
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:01:49 +02:00
ZetenypappandClaude Opus 5.5 172952f6e0 Find: "Find what:" has the keyboard when the dialog opens (#951)
Ctrl+F and typing searches without clicking the field first, and the field
keeps the keyboard after Enter finds a match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:00:41 +02:00
NetterSilverandClaude Haiku 5.5 e54bd2ccb7 Read a newline in pasted HTML text as a space
Found while testing crates/engine/src/cmd/richclip.rs:257-283 (decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:57:05 +02:00
ZetenypappandClaude Opus 5.5 8e9c198c7c Duplicate repeats the move of the last copy, as in PowerPoint (#966)
Duplicating a shape, moving the copy and duplicating that copy again places
the next copy the same distance further on. Anything else, an undone copy or
another slide gets the usual offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:47:04 +02:00
Dylan M. Taylor cb78440a16 Merge pull request #952 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-18 11:32:28 -04:00
Eduardo GarciaandClaude Sonnet 5.5 601823d98c Declare the a: and r: namespaces on the modern comments part (#974)
p188:cmLst declared only xmlns:p188 but its comment bodies use a:bodyPr, a:p, a:r and
a:t, so the part was not namespace-well-formed and PowerPoint refused the saved file.
A regression test parses every written XML part with a namespace-aware reader.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:22:07 -03:00
Brandon Thomas 868016ee2f Merge pull request #882 from marcoarnulfo/fix/show-anim-steps 2026-10-18 11:21:27 -04:00
Brandon Thomas 0981e6e675 Merge pull request #871 from marcoarnulfo/feat/alignto 2026-10-18 11:21:26 -04:00
Brandon Thomas 006bbe1136 Merge pull request #864 from marcoarnulfo/feat/ppsx-starts-show 2026-10-18 11:21:26 -04:00
Brandon Thomas dfe40a17bf Merge pull request #856 from marcoarnulfo/feat/autoformat-ordinals 2026-10-18 11:18:18 -04:00
Brandon Thomas 0b6705c12c Merge pull request #943 from Zetenypapp/fix/click-in-front-931
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:18 -04:00
Brandon Thomas ef6e8cdc2f Merge pull request #946 from Zetenypapp/fix/edit-points-undo
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:17 -04:00
Brandon Thomas 9430673350 Merge pull request #886 from Silvertree2010/fix/h2169-shape-setbounds-shape-resize-acc
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:16 -04:00
Brandon ThomasandClaude Opus 5.5 efc279d6f2 Clamp overflowing geometry to the limit and keep rotated resizes in range
An overflow to infinity (move x=1e308 dx=1e308) now lands on the limit
instead of resetting to 0, and a rotated shape re-centred after a capped
resize stays within POS_LIMIT (CodeRabbit review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:18:16 -04:00
Brandon Thomas c1184d64fd Merge pull request #902 from Silvertree2010/fix/h2405-comment-delete-with-an-invalid-i 2026-10-18 11:17:29 -04:00
Brandon Thomas 8b01ed3e88 Merge pull request #901 from Silvertree2010/fix/h2367-flip-horizontal-flip-vertical-on
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:29 -04:00
Brandon Thomas 9e1430e296 Merge pull request #898 from Silvertree2010/fix/h2311-change-case-capitalize-each-word
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:28 -04:00
Brandon Thomas fab77f45e5 Merge pull request #979 from MarekSpodBiedry/issue-968 2026-10-18 11:17:20 -04:00
Brandon Thomas 11bc157477 Merge pull request #928 from FabPei/agent/issue-845 2026-10-18 11:17:19 -04:00
Brandon Thomas 389c2c2921 Merge pull request #885 from switch87/feat/784-transparent-png-export 2026-10-18 11:17:19 -04:00
Brandon Thomas 4fcf593bac Merge pull request #948 from Zetenypapp/fix/duplicate-shape-ids
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:06 -04:00
Brandon Thomas 26371b0629 Merge pull request #947 from Zetenypapp/fix/huge-effect-sizes
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:06 -04:00
Brandon Thomas dfa91441e6 Merge pull request #802 from Faabyy/import-warnings-203
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:05 -04:00
Brandon Thomas 9eadf35c97 Merge pull request #893 from Silvertree2010/fix/h2209-reuse-slides-slide-reuse-leaves 2026-10-18 11:16:19 -04:00
Brandon Thomas f5a895ef75 Merge pull request #827 from Silvertree2010/fix/h1748-slide-fromoutline-insert-slides 2026-10-18 11:16:18 -04:00
Brandon Thomas 0883d89e77 Merge pull request #796 from Silvertree2010/fix/h1431-outline-export-writes-a-multi-pa 2026-10-18 11:16:18 -04:00
Brandon Thomas eb417f0470 Merge pull request #795 from Silvertree2010/fix/h814-html-paste-drops-text-from-a-bar 2026-10-18 11:16:17 -04:00
Dylan M. Taylor 483a351c37 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:01:49 -04:00
Dylan M. Taylor 19fadde1cc docs: clarify that feature percentage is measured against tracked features in docs/target-app-parity.md 2026-10-18 10:47:26 -04:00
Alexey 46dec4a660 fix(wasm): avoid std services that panic on wasm32-unknown-unknown at startup
recovery::instance_id() mixed std::process::id() into the instance hash —
on wasm32-unknown-unknown that panics (``no pids on this platform``, and
SystemTime is missing too), so the web app died before its first frame.
On wasm the browser clock (js_sys::Date::now(), already an engine dep for
comment timestamps) stands in.
2026-10-18 15:27:59 +02:00
NetterSilverandClaude Haiku 5.5 45eeb7c819 Add slides from an outline to the section of the slide they follow
Found while testing crates/engine/src/cmd/slide.rs:~393 (from_outline), crates/format/src/lib.rs:225 (add_outline_slides)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:18:49 +00:00
MarekSpodBiedry 6dca3e860b Hide the idle cursor over clickable clips during a slideshow (#968) 2026-10-18 13:53:14 +02:00
NetterSilverandClaude Haiku 5.5 3e729bbf08 Limit shape.setBounds, shape.resize and shape.move size and position
Found while testing crates/engine/src/cmd/shape.rs:215 (set_bounds; shape.resize at :~183 and shape.move at :~171 have the same problem)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 10:19:53 +00:00
NetterSilverandClaude Haiku 5.5 3f973b0d87 Mirror the rotation when flipping a rotated shape
Found while testing crates/engine/src/cmd/shape.rs:400-412 (flip; used by arrange.flipHorizontal / arrange.flipVertical)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:17:00 +02:00
NetterSilverandClaude Haiku 5.5 e5a3c9ca65 Change case across formatting run boundaries
Found while testing crates/engine/src/cmd/format.rs:693-715 (also the whole-shape path at :734-741)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:13:47 +02:00
ZetenypappandClaude Opus 5.5 8ee5094c60 Open: also cap effect sizes in inherited text styles and theme effects
Review follow-up. A huge glow or shadow could still arrive through what text and shapes inherit:
the deck's default text style, a master's title, body and other styles, a text box's or table
cell's own list style, and the theme's effect styles that shape styles point at. sane_numbers caps
those too; the test plants 10^18 pt glows in each and checks they come back capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:41:36 +02:00
Faabyy 368e07493c PPTX: a slide that can't be read is reported, not dropped in silence (#203)
The importer skipped a slide whose part was missing or unreadable and only
logged it, so `info` showed one slide fewer with exit code 0 and a save wrote
the shorter deck over the original.

`deckcraft_pptx::import_checked` returns the parts it left out (`import` is
unchanged). The engine carries them as `Damage::slides` next to the damaged
media of a `.deckcraft` file: `file.open` and `file.openBytes` return
`lostSlides`, the app shows a message and opens a repaired copy (so Save asks
for a new name), and `deckcraft-cli` warns on stderr.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 1dc6972
# Last command done (1 command done):
#    pick d2e995c PPTX: a slide that can't be read is reported, not dropped in silence (#203)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   Cargo.lock
#	modified:   ROADMAP.md
#	modified:   apps/deckcraft-cli/Cargo.toml
#	modified:   apps/deckcraft-cli/src/main.rs
#	new file:   apps/deckcraft-cli/tests/damaged.rs
#	modified:   crates/engine/src/cmd/file.rs
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/pptx/src/lib.rs
#	modified:   crates/pptx/src/read/mod.rs
#	modified:   crates/pptx/tests/malformed.rs
#	modified:   docs/file-format-parity.md
#


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 22db926
# Last command done (1 command done):
#    pick 31b0d33 PPTX: a slide that can't be read is reported, not dropped in silence (#203)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   Cargo.lock
#	modified:   ROADMAP.md
#	modified:   apps/deckcraft-cli/Cargo.toml
#	modified:   apps/deckcraft-cli/src/main.rs
#	new file:   apps/deckcraft-cli/tests/damaged.rs
#	modified:   crates/engine/src/cmd/file.rs
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/pptx/src/lib.rs
#	modified:   crates/pptx/src/read/mod.rs
#	modified:   crates/pptx/tests/malformed.rs
#	modified:   docs/file-format-parity.md
#
2026-10-18 09:18:46 +00:00
ZetenypappandClaude Opus 5.5 051d3b4fd3 Canvas: shapes in front of grouped text and of the edited box's edge take the click too
Review follow-up. The in-front check now also looks among the members of the group that holds
the edited text, and the edited box's frame (which drags it) yields to a shape in front of the
text at that spot; the box's own handles stay on top, as in PowerPoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:56:38 +02:00
ZetenypappandClaude Opus 5.5 daae74b57e Open: cap absurd effect sizes from a file at what Format Shape allows
A .pptx or .deckcraft could give a glow, soft edge, shadow or reflection any size. A glow of
~10^16 pt made vello panic while flattening, one of 10^18 pt made rendering ask for 34 GB and abort.
sane_numbers (run by repair on every opened file) now caps them like line widths and font sizes:
glow 150, soft edges 100, shadows 200, reflections 100 pt; shadow scale and skew are kept sane and
non-numbers become 0, on shapes and on text runs, table cells included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:07:58 +02:00
ZetenypappandClaude Opus 5.5 5e8c1bcc51 Open: give shapes that repeat an id in a .deckcraft file fresh ones
repair re-id'd repeated slide ids but not shape ids, so such a file opened as a deck that fails
validate, and edits by id found only the first of the shapes sharing one. Repeats on a slide,
layout or master (groups included) now get fresh ids from the deck's counter; the first keeps
its id, and decks without repeats aren't copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:04:22 +02:00
ZetenypappandClaude Opus 5.5 f4afe6d2df Edit Points: turning a shape into a freeform is one undo step
shape.editPoints was a noundo command, so the conversion from a preset to custom geometry was
never recorded and Ctrl+Z took back the step before it. It is undoable now (only an actual change
records a step), and Edit Points ends when Undo turns the shape back into a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:01:46 +02:00
ZetenypappandClaude Opus 5.5 a773fcfce9 Canvas: a shape in front of edited text takes the click (#931)
While a placeholder's text was being edited, place_caret took every click inside the text box
before the hit test, so a shape in front of it couldn't be selected. It now leaves the click to
the hit test when a shape above the one holding the text is under the pointer; a click on the
text where nothing is in front still moves the caret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:59:58 +02:00
Dylan M. Taylor 121882e177 docs: drop references to competing commercial software 2026-10-18 01:20:12 -04:00
Brandon ThomasandClaude Opus 5.5 22db9261f9 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
Brandon ThomasandClaude Opus 5.5 19724866a6 Tests: fix two flakes (recovery lock held by a forked mkfifo; notes check not in yet)
recovery::another_running_instance_keeps_its_entries unlocks the other
instance's lock instead of only dropping it: a mkfifo child spawned by a
parallel test shares the open file until it execs, keeping the flock held
(seen on the FreeBSD VM).

spelling_tests::notes_menu_language_dialog_and_repeated_words waits for
the background notes check before right-clicking (seen on ubuntu CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
NetterSilverandClaude Haiku 5.5 83f21e7cb9 Fix add overflow panic in table.columnWidth for a huge column index (#922)
Found while testing crates/engine/src/cmd/table.rs:540 (column_width)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:36:12 -06:00
NetterSilverandClaude Haiku 5.5 bdcbee8d0b Make edit.cut a no-op when only a text caret is set (#920)
Found while testing crates/engine/src/cmd/edit.rs:cut (about line 337) and delete (about line 523)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:35:36 -06:00
Marco Arnulfo 23144e36fe Contributors: add my name for the About window (#857) 2026-10-17 18:35:13 -06:00
skylar taylor-barrick a178b9955b Merge pull request #849 from MMexicann/fix/pptx-trigger-click-steps
PPTX: preserve separate click steps in trigger sequences
2026-10-17 14:18:58 -06:00
skylar taylor-barrick 2cf4535c5d Merge pull request #939 from skylartaylor/control-input
Close the control port on the first line that isn't a request
2026-10-17 12:14:05 -06:00
Fabian 887e868a46 fix: DeckCraft upstream #845
PDF export rasterized every slide as one full-page bitmap, so the
background, master and layout graphics a deck repeats were stored once
per slide. Draw each slide as its backdrop plus the slide's own shapes;
equal backdrops become equal images and the writer stores each once.
2026-10-17 15:52:54 +02:00
Mex 2a4adf3024 Merge branch 'main' into fix/pptx-trigger-click-steps 2026-10-17 13:29:55 +02:00
Lexx143andClaude Fable 5.1 9632162029 macOS: offer .potx and .ppsx too, keep the desktops' file types equal, log the change
Windows (deckcraft.wxs) and Linux (the desktop file) already offer DeckCraft for .deckcraft,
.pptx, .potx and .ppsx; the macOS Info.plist now declares the same four, so a template or a
slide show opens from Finder as well. A test in apps/deckcraft reads the three packaging files
and fails when they drift apart, and checks every document type names its content type (Finder's
"Change All" needs it). Progress log row for #138 and #347.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 12:17:45 +05:00
cohenmendyandClaude Opus 5.5 12085b1153 fix(macos): open .pptx and .deckcraft files from Finder
The macOS bundle declared the .deckcraft and .pptx types but listed no
CFBundleDocumentTypes, so DeckCraft never appeared under Open With and
couldn't be made the default app for presentations. Finder opens also
arrive as a kAEOpenDocuments Apple event, not argv, which winit doesn't
handle, so a double-clicked file wouldn't have loaded anyway.

- Info.plist.in: document types for DeckCraft Presentation (Owner) and
  PowerPoint Presentation (Alternate, so PowerPoint/Keynote stay
  preferred unless the user picks DeckCraft).
- apple_events.rs: register an open-documents handler with the pinned
  fmv-macos-events crate GridCraft already uses, and open the paths each
  frame; failures show in the status bar.
- Ignore the -psn_ argument older macOS passes on a Finder launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxAm8VMKHa5w6kwkjBaT3D
2026-10-17 12:17:45 +05:00
MarekSpodBiedry b0006d6af8 About: Contributors and Models get their own fixed-size block below the tabs (#843) 2026-10-17 07:46:18 +02:00
NetterSilverandClaude Haiku 5.5 70244c899d Reject invalid comment.delete index instead of clearing the slide
Found while testing crates/engine/src/cmd/review.rs:140-155 (delete)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:19:50 +02:00
Bryson Meiling 8f4b0d4c3f text: test the whole Adobe Symbol encoding table
The symbol_unicode table has 130 entries; only U+F0DE was covered by a test.
Add symbol_unicode_table_covers_the_adobe_encoding, which asserts every
code->real-Unicode mapping directly against the authoritative
unicode.org/MAPPINGS/VENDORS/ADOBE/symbol.txt table, plus that ASCII
pass-through and CUS fragment codes return None. It is pure and
platform-independent (no fonts involved), so it runs on every machine and
catches any typo in the table.
2026-10-16 14:50:09 -06:00
Bryson Meiling 271c6c4037 text: remap symbol-font U+F0xx codes to the installed Symbol face's real Unicode
PowerPoint stores a Symbol-font character as U+F0xx with <a:sym typeface="Symbol"/>:
U+F0DE is the Symbol font's double arrow (=>). The layout drew it from whichever
installed face happened to map U+F0DE (Wingdings draws an arrow at another angle),
because Apple's "Symbol" on macOS has no private-use cmap at all — it maps the
glyphs to real Unicode instead. The test that checked this skipped on macOS.

Add the Adobe Symbol encoding table (unicode.org MAPPINGS/VENDORS/ADOBE/symbol.txt;
Apple's Mac OS Symbol is laid out identically) and remap a document's U+F0xx code to
the real Unicode character the installed Symbol face draws it at: the PUA code when
the face has one, else the encoding's real character (U+F0DE -> U+21D2 =>).

The remap applies wherever a symbol-font character is drawn:
- text runs with an a:sym font (shape_cells picks the face, shape_item remaps during shaping),
- bullets (symbol_font_char),
- metafile text (symbol_char).

The previously-skipped symbol_font_characters_draw_in_the_sym_font test now runs on
macOS (its guard checks the remapped character), and a new test checks U+F0DE draws
as U+21D2 on Apple's Symbol.
2026-10-16 14:46:07 -06:00
Kushagra Kumar 432016fc96 Propagate Format Background Apply to All to slide masters (#891) 2026-10-17 00:06:26 +05:30
NetterSilverandClaude Haiku 5.5 d3fb412167 Remap slide links to the copies when reusing slides
Found while testing crates/engine/src/cmd/reuse.rs:~150 (reuse; slides get fresh ids but Action::Slide targets are never remapped)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:33:41 +00:00
aiongg 7f1979a1cc Close control connections on the first line that is not a request
A line that is not a JSON object with a string method, including an HTTP
request line or bytes that are not UTF-8, gets one error reply and the
connection closes before anything after it is read.
2026-10-16 16:17:41 +00:00
Gert Pellin 392734a135 Export: transparent background docs and clearer refusal (#784)
The error for a non-PNG export says the option is for PNG export. ROADMAP,
file-format, UI parity and control protocol docs mention the option.
2026-10-16 16:42:42 +02:00
Gert Pellin 39e7fb1d4f Export: PNG with a transparent background (#784)
The renderer lays every slide background over white (as PowerPoint does),
so a PNG export was always opaque. A new transparentBackground option leaves
the slide background out (both the white underlay and the background fill),
so only what is on the slide is drawn over transparency. Shapes filled with
the background (useBgFill) still show it.

- render: RenderOpts::transparent_background
- engine: file.export {transparentBackground} (PNG only; other formats
  refuse it and write nothing), file.render {transparentBackground}
- UI: Transparent background checkbox in the Export dialog (PNG) and in
  File > Export > Change File Type > PNG; the web download honours it
- CLI: render --transparent
2026-10-16 16:30:52 +02:00
Marco Arnulfo c7423aa986 Show: Back leaves a slide whose first animation plays by itself (part of #844) 2026-10-16 15:47:21 +02:00
Marco Arnulfo 8c41140abe Arrange: Align to Slide and Align Selected Objects in the Align menu, as in PowerPoint 2026-10-16 07:47:16 +02:00
NetterSilverandClaude Haiku 5.5 b6af19ae35 Keep multi-paragraph slide titles on one line in outline export
Found while testing crates/format/src/lib.rs:490

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 07:15:31 +02:00
skylar taylor-barrick 1dc69720db Merge pull request #866 from Silvertree2010/fix/h1968-table-insertrowbelow-insertcolum
Clamp huge index in table.insertRowBelow/insertColumnRight
2026-10-15 23:13:10 -06:00
skylar taylor-barrick 1b64de3499 Merge pull request #816 from albayazid/fix/outline-tables-814
Format: .txt and .md outline includes table cell text (#814)
2026-10-15 22:48:23 -06:00
skylar taylor-barrick ad6be08603 Merge pull request #798 from Silvertree2010/fix/h1435-paste-from-html-text-inside-b-st
Don't paste HTML text as bold when <b> sets font-weight:normal
2026-10-15 22:43:42 -06:00
skylar taylor-barrick e5620b538a Merge pull request #797 from Silvertree2010/fix/h1447-outline-import-deletes-leading-a
Keep leading '-', '*' and '•' text when importing outlines
2026-10-15 22:43:12 -06:00
NetterSilverandClaude Haiku 5.5 1bb408462c Clamp huge index in table.insertRowBelow/insertColumnRight
Found while testing crates/engine/src/cmd/table.rs:291

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 04:09:45 +00:00
Dylan M. Taylor 6d98534a27 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:56 -04:00
Marco Arnulfo e755d3eeb1 Opening a .ppsx from the command line starts the slide show (#862) 2026-10-16 05:44:02 +02:00
skylar taylor-barrick f4731822c9 Merge pull request #854 from dylanmtaylor/ci/add-windows-arm64
ci: add shared Windows ARM64 and nix checks
2026-10-15 21:26:51 -06:00
Dylan M. Taylor f9af68a06f ci: add shared Windows ARM64 and nix checks 2026-10-15 20:48:37 -04:00
skylar taylor-barrick be2f10d8cb Merge pull request #822 from Silvertree2010/fix/h1632-slide-duplicate-appends-the-copy
Keep slide.duplicate copies next to their source in the section
2026-10-15 18:29:50 -06:00
skylar taylor-barrick acea4e3f14 Merge pull request #847 from Silvertree2010/fix/h1767-shape-connect-and-shape-sites-po
Reject shape ids beyond u32 in shape.connect, sites and points
2026-10-15 18:29:04 -06:00
Marco Arnulfo 367c3488c5 Text: AutoFormat superscripts ordinals (1st, 22nd) as you type 2026-10-16 02:06:42 +02:00
Mex c8dc372519 M6.1: preserve PPTX trigger click steps
Keep later On Click effects and paragraph builds in separate interactive steps.
Add round-trip and headless show regressions.

Contribution on behalf of @MMexicann.
2026-10-15 22:31:32 +02:00
NetterSilverandClaude Haiku 5.5 d7509e7ad6 Reject shape ids beyond u32 in shape.connect, sites and points
Found while testing crates/engine/src/cmd/shape.rs:1154 (shape_id, used by connect/sites); same pattern at crates/engine/src/cmd/points.rs:358-359 and :432-433

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 22:09:59 +02:00
NetterSilverandClaude Haiku 5.5 595d8f17c1 Keep slide.duplicate copies next to their source in the section
Found while testing crates/engine/src/cmd/slide.rs:217-220

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:07:07 +02:00
Sazid Al Bayazid 59d5893793 Format: .txt and .md outline includes table cell text (#814)
slides_to_outline only looked at sh.text, which regular text boxes and
placeholders carry. Tables store their text in cells inside
ShapeKind::Table, so convert to .txt or .md silently skipped every
table cell — with exit 0 and no warning.

The outline now walks into tables: each row becomes a tab-separated
line (multi-paragraph cell text is flattened with spaces). Verified
with the issue's python-pptx repro: the four cell texts appear after
the slide title and text box body.

Tests: a deck with a text box and a 2x2 table — the outline contains
all three text sources.
2026-10-15 18:53:01 +06:00
skylar taylor-barrick 0954289c62 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-15 18:51:30 +06:00
NetterSilverandClaude Haiku 5.5 062adfe405 Don't paste HTML text as bold when <b> sets font-weight:normal
Found while testing crates/engine/src/cmd/richclip.rs:187

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:52:15 +00:00
NetterSilverandClaude Haiku 5.5 aa681eafad Keep leading '-', '*' and '•' text when importing outlines
Found while testing crates/format/src/lib.rs:421

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:43:29 +02:00
NetterSilverandClaude Haiku 5.5 c39d71a51a Keep bare '&' text when pasting HTML instead of dropping it
Found while testing crates/engine/src/cmd/richclip.rs:257-283 (decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:34:39 +00:00
Hamza 79088b7060 Text: an empty line takes the size of the mark that ends it (#780)
An empty line inside a paragraph -- between two `a:br`, or after a trailing one
-- was laid out at the size of the paragraph's *first run*. PowerPoint sizes it
by the mark that closes the line: the `a:rPr` of the `a:br` that ends it, or
`a:endParaRPr` for the paragraph's last line. Every line below then sits too
high or too low, by the difference; in the reported poster deck the text drifted
by up to 173pt.

`shape_para` resolved one `lead_size`/`lead_face` per paragraph and
`layout_scaled` used it for every line with no measured cells. A break is a
cell (`\u{b}`) and `break_lines` sets a line's `end` to that break's own index,
so the closing mark is already to hand: take its style, falling back to the
paragraph mark (now carried as `end_size`/`end_face`) for a last line that no
break closes. Any other empty line -- a line of tabs alone -- keeps the old
measure, so only the two cases PowerPoint defines change.

An empty *paragraph* already landed on `end_props` through `lead`, and is
unchanged: it is the control case in the report, and the test for it passes both
before and after.

Measured on the reported fixture, second line top against PowerPoint's own
export: case A 166pt -> 130pt (131 expected), case B 166 -> 130, case C 113 ->
161 (160 expected), and the control case D 130 either way. The 1pt that remains
is the offset the control already had.
2026-10-15 12:33:20 +05:00
skylar taylor-barrick 9859da3925 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-14 21:20:34 -06:00
skylar taylor-barrick 09c7f60567 PDFW2: integrate current main with shared PDF writing 2026-10-14 21:00:04 -06:00
Brandon Thomas 155c1cfce2 Merge PR #776 from skylartaylor: ci: add comfortable runtime limits to unbounded jobs 2026-10-14 22:42:44 -04:00
skylartaylor 6b03a3f29c ci: add comfortable runtime limits to unbounded jobs 2026-10-15 04:27:05 +02:00
Brandon ThomasandClaude Opus 5.5 a15d93acdd Fix up #747 and #772 from review: docs keep their status headers (log rows stay), no milestone renaming; the bullet test no longer saves screenshots; clippy's as_chunks in the bullet render test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:00:48 -04:00
Brandon Thomas 561902d0b3 Merge PR #772 from hajdukste: Text: show gray list markers before typing
# Conflicts:
#	crates/render/src/tests.rs
#	crates/text/src/tests.rs
2026-10-14 21:59:44 -04:00
Brandon Thomas a74191dba4 Merge PR #747 from MMexicann: MCP: validate reply IDs and bounded response frames (#718) 2026-10-14 21:59:44 -04:00
Brandon Thomas 593fd215ff Merge PR #773 from Silvertree2010: Grow table merges to include merged areas they overlap 2026-10-14 21:59:39 -04:00
Brandon Thomas 1cba8f0f60 Merge PR #769 from switch87: Preferences: Interface size applies when the slider is let go 2026-10-14 21:59:39 -04:00
Brandon Thomas 404d303c2e Merge PR #768 from switch87: Render: a cropped picture keeps its resolution (Intersect no longer blurs it) 2026-10-14 21:59:39 -04:00
Brandon Thomas e4fd7a29cc Merge PR #760 from albayazid: Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736) 2026-10-14 21:59:39 -04:00
Brandon Thomas 0b870df463 Merge PR #759 from midasmatterhorn: text: a bare normAutofit draws at 100% and an edit stores the fitted scale (#724) 2026-10-14 21:59:39 -04:00
Brandon Thomas 816f256c5f Merge PR #758 from midasmatterhorn: control: a request that timed out while queued is abandoned, never run late (#719) 2026-10-14 21:59:38 -04:00
Brandon Thomas 60fa10f99f Merge PR #757 from switch87: Preferences: Cancel, Esc and the close button undo the dialog's changes (#752) 2026-10-14 21:59:38 -04:00
Brandon Thomas 82ef9642a2 Merge PR #749 from midasmatterhorn: media: walk ID3 tag chains iteratively so crafted files cannot overflow the stack 2026-10-14 21:59:38 -04:00
Brandon Thomas 672d86c0e4 Merge PR #746 from IanHollow: Add an editor screenshot to AppStream metadata 2026-10-14 21:59:38 -04:00
Brandon Thomas 54d3f19b5b Merge PR #738 from xmaks82: Fonts: falling back to the default face no longer deadlocks 2026-10-14 21:59:38 -04:00
Brandon Thomas 2c57163549 Merge PR #734 from switch87: Slide: a new slide's body goes into the layout's content placeholder 2026-10-14 21:59:38 -04:00
skylar taylor-barrick 0cbe475f13 fix(pdf): reconcile writer with notes and handout masters (PDFW)
Preserve notes and handout master export, visible text and native parallel
compression while propagating shared font errors through each export path.
Keep the dependency check accurate for native and WASM feature resolution.

PR #708.
2026-10-14 19:44:21 -06:00
NetterSilverandClaude Haiku 5.5 0222a90e5d Grow table merges to include merged areas they overlap
Found while testing crates/model/src/table.rs:224 (Table::merge)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:07:48 +00:00
hajdukste 51de1ad2cf Text (#93): show gray markers on empty list paragraphs 2026-10-14 17:58:53 -07:00
midasmatterhorn 8d3e4a630f fix(media): prevent overflow while probing ASF headers
Use checked arithmetic for ASF header-object bounds and cursor advances so hostile object sizes cannot panic. Keep a synthetic regression for the malformed header.

Addresses #750 (ASF object-size overflow).
2026-10-14 18:07:22 -06:00
Gert Pellin 8b08718b9a Preferences: Interface size applies when the slider is let go (#767)
Dragging the Interface size slider set the interface scale on every
frame, which grew or shrank the slider under the pointer and ran it to
one of its ends. Keep the value pending in the dialog while the pointer
holds the slider and apply it on release; keyboard changes still apply
at once.
2026-10-15 01:49:41 +02:00
Gert Pellin 7cda7eda1d Merge origin/main into fix/725-body-placeholder
Both sides added tests at the same place in engine tests.rs; keep both.
Document::alloc_id now returns an Option, so the #725 test unwraps it.
2026-10-15 01:39:24 +02:00
Gert Pellin 57c03d36fc Render: decode a cropped picture for the whole image's size, not its box's (#762)
A picture was decoded at the size of its box on screen. A cropped picture
shows only part of the image, magnified, so it was decoded at a fraction of
the resolution it needs and stretched back up: Merge Shapes > Intersect,
which crops the picture to the new smaller box, left it visibly blurred.
Size the decode from the rectangle the whole image occupies instead.
2026-10-15 01:28:10 +02:00
Sazid Al Bayazid 40e05abd45 Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736)
anchorCtr's centreing offset was computed for a block that starts at
the box's left edge and then added to lines that alignment had already
placed within the box. For a right-aligned line the two shifts
stacked: the line ended up starting at the box's centre and ran past
the right edge - on the tdf165341 fixture the sentence was cut off at
"...paragraph rig". The clamp at zero made it worse for content wider
than the box: all the overflow went to the right only.

The origin is now set so the drawn text block's own centre (min/max of
the laid-out line extents) lands on the box's centre: every alignment
stays inside the box when it fits, and overflow centres symmetrically
past both edges, as PowerPoint draws it. Empty lines carry no ink and
stay out of the block; vertical text and multi-column bodies are
unchanged (centring is unimplemented for them, as before).

Tests: right/left-aligned, trailing empty paragraph, mixed alignment,
symmetric overflow, and a no-anchorCtr control - the block's centre
equals the box's centre in every case.

Reported with a public LibreOffice test fixture (tdf165341.pptx);
verified by rendering the slide before (text cut at the right edge)
and after (whole sentence centred).
2026-10-15 04:41:37 +06:00
Gert Pellin b69613bf88 Preferences: Cancel, Esc and the close button undo the dialog's changes (#752)
The Preferences dialog applies its controls as they change (appearance,
interface size, the editing checkboxes, the comment author), and its Cancel
result was discarded, so Cancel did nothing and the window stayed open.

The dialog now remembers the settings it opened with. Cancel, Esc and the
window's close button put them back and close it; Done keeps the changes.
2026-10-14 23:58:43 +02:00
midasmatterhorn a82f691993 text: a bare normAutofit draws at 100% and an edit stores the fitted scale (#724) 2026-10-15 00:56:41 +03:00
midasmatterhorn 82ee5dec25 media: walk ID3 tag chains iteratively so crafted files cannot overflow the stack 2026-10-15 00:55:32 +03:00
midasmatterhorn 2568c55afa control: a request that timed out while queued is abandoned, never run late (#719) 2026-10-15 00:37:58 +03:00
Brandon ThomasandClaude Opus 5.5 ea904591a5 Fix flaky test: the missing-Recent test waits for the background open (#291 after #488)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:33:37 -04:00
Brandon Thomas afe4a408b9 Merge PR #438 integrated on land-r9: an exhausted id space refuses growth instead of duplicating ids (#407)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 16:25:59 -04:00
Brandon ThomasandClaude Opus 5.5 c79416be4c Fix up merged PRs: no debug_assert in blank_presentation; more exhaustion coverage
blank_presentation's debug_assert would panic in debug builds (never-crash
rule): a fresh deck that cannot fit its title slide now simply arrives
without it. The exhaustion test also covers insert.smartArt and
insert.textBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:24:26 -04:00
Brandon ThomasandClaude Opus 5.5 67e1ab6373 Merge PR #438 from albayazid: Model: an exhausted id space refuses growth instead of duplicating ids (#407)
Conflicts resolved onto main's newer shape, and the PR's policy applied to
every alloc_id/add_media/new_slide call site main has grown since: commands
answer bad(cmd, NO_ID_SPACE) (slide.reuse, insert.zoom, SmartArt, photo
album, review.accept, handout/notes masters, master placeholders,
background/artistic/compress pictures, transition sound, ink to shape,
paste special, file.new templates); reid/bring_media/renumber/regenerate
and compare::adopt/accept are fallible; outline import keeps what fit.
Tests use expect() on the now-Option results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:24:26 -04:00
Mex f4ce764d1c M13.1: validate remote MCP replies (#718)
Reject unrelated, malformed, incomplete and oversized replies without replaying commands.
Reconnect before sending when unsolicited reply bytes are buffered.

Contribution on behalf of @MMexicann.
2026-10-14 22:24:15 +02:00
Brandon ThomasandClaude Opus 5.5 b1ca1230e6 Engine: text.delete and text.move are registered once (the newer docs win); a test keeps command ids unique
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:15:17 -04:00
IanHollow 16bef64ea1 Add an editor screenshot to AppStream metadata
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com>
2026-10-14 13:13:44 -07:00
Brandon ThomasandClaude Opus 5.5 4517dd4e88 Docs: fold the rows and headers the PR merges duplicated; parity summary regenerated
Union merges of the 2026-10-14 community PR wave stacked header lines and
left near-duplicate rows, bullets and paragraphs with per-PR counts. Each
progress doc now has one status line and one row per fact, with current
numbers: breadth 84% over 191 features (cargo xtask parity, after removing
duplicate Thesaurus/AutoCorrect/Artistic effects/Corrections checklist
rows), 320 engine commands, 41 dialogs, 1,641 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:13:37 -04:00
Brandon Thomas 1370e78e09 Merge the land-fix PRs integrated on land-r8 (#355 #509 #513 #508 #488)
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 15:59:30 -04:00
Brandon ThomasandClaude Opus 5.5 0997d1e4cd Merge PR #520 from NikitkaRa: Insert: Summary, Section and Slide Zoom with Return to Zoom
Fix-ups in the merge (review): zoom targets, sections and titles are looked up with get()
instead of indexing; Hyperlink literals carry both main's sound fields and zoom_return;
duplicated test statements left by the test-file union are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:58:31 -04:00
Brandon ThomasandClaude Opus 5.5 5006f4cc84 Fix up merged PRs: slide.reuse's params doc uses commas
The landing branch checks that every params doc parses into its keys; #509's
used `;` between keys, which that parser rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:56:55 -04:00
Brandon ThomasandClaude Opus 5.5 10eb4866d9 Fix up merged PRs: the media walker counts #209's kept object parts
SmartArt and OLE objects now keep their parts as media items
(ShapeKind::Opaque::parts). media_refs visits them, so slide.reuse copies them
and picture.compress never deletes them; the Opaque arm names every field so a
new one can't be missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:53:57 -04:00
Brandon Thomas bb15c0a033 Merge the landing branch into land-r8
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/cmd/file.rs
#	crates/pptx/src/opc.rs
#	crates/pptx/tests/roundtrip.rs
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 15:53:57 -04:00
Brandon ThomasandClaude Opus 5.5 76978b1634 Fix up merged PRs: a background open that fails says why on the start screen
Main (#346) shows why a file couldn't be opened as a notice over the start
screen; #488's background open put the error in the status bar instead, which
broke a_file_that_cant_be_opened_says_why. The background open now reports
through the same notice, and the test waits for the background read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:50:07 -04:00
Brandon ThomasandClaude Opus 5.5 a7d47dbc1b Fix up #488: background opens enter the session through file.adopt
The progress window's result was added with add_presentation_file, straight
into the session: not journaled and outside the command panic guard that
file.open runs under. The host now hands the loaded file to
Session::stage_loaded and runs the new internal command file.adopt {path,
token}, which shares file.open's tail (repaired-copy handling, damaged-media
report) and so is journaled and guarded. Without the staged copy (a replayed
journal, a stale token) file.adopt reads path like file.open. Reading still
happens on the background thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:47:24 -04:00
Brandon Thomas 62bb8d62e6 Merge PR #488 from NikitkaRa: UI: open presentations in the background with a progress window (#350)
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 15:43:43 -04:00
Brandon ThomasandClaude Opus 5.5 b54ec601d8 Fix up #508: read 3-D settings with the XML parser; keep only known presets
Shape3d::parse scanned the kept a:scene3d/a:sp3d XML as strings and copied the
camera, light rig, direction, material and bevel names from the file into the
XML it writes back unescaped. It now parses the fragment with the importer's
XML reader (exported from deckcraft-pptx as parse_xml) and keeps a value only
when it is in the format's lists (ST_PresetCameraType, ST_LightRigType,
ST_LightRigDirection, ST_PresetMaterialType, ST_BevelPresetType), else the
default; lengths are clamped and the extrusion/contour colours are
re-serialized (escaped) by the XML writer. The Format Shape pane rows in
ui-parity.md that earlier merges had duplicated are folded into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:42:23 -04:00
Brandon Thomas 198659a697 Merge PR #225 from xmaks82 (rebased head): Render: an unfilled shape's glow follows its line
# Conflicts:
#	crates/render/src/lib.rs
2026-10-14 15:40:36 -04:00
Brandon Thomas b4be808ba3 Merge PR #508 from NikitkaRa: Format Shape: 3-D Format and 3-D Rotation controls
# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 15:40:11 -04:00
Brandon ThomasandClaude Opus 5.5 6050c04ecd Fix up #513: keep every referenced media; cap picture decoding
picture.compress decided which originals were still in use by scanning JSON
keys, which missed artistic-effect originals (and the embedded-font shape it
assumed no longer exists), so compressing a picture whose bytes were also
another picture's artistic original deleted media still in use. It now uses
the engine's typed media walker (media_refs::presentation_media).

Decoding is capped: the header's size is read first and pictures over 64 MP
are left alone, and the decoder runs with image::Limits (512 MiB allocation),
instead of decoding up to 400 MP before checking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:39:15 -04:00
Brandon Thomas 70a9a5b1cd Merge PR #513 from NikitkaRa: Pictures: compress pictures to a target resolution
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/pptx/tests/roundtrip.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 15:36:54 -04:00
Brandon Thomas 10d4921d41 Merge the land-fix PRs integrated on land-r7 (#67 #87 #88 #136 #137 #139 #150 #151 #169 #209 #225 #247 #321) 2026-10-14 15:36:14 -04:00
Brandon ThomasandClaude Opus 5.5 5ef510e57b Fix up #509: copy media by a typed walk of every reference
slide.reuse indexed src.slides[i] and found media by scanning JSON keys
(media/poster/preview/sound), which missed artistic-effect originals and would
miss any reference stored under another key. New engine module media_refs: one
typed walker over the model (written once, expanded for & and &mut) that visits
every MediaId a presentation, master, layout, slide or shape can hold: pictures
and their artistic originals, picture fills in shapes, lines, runs, table cells
and styles, chart series and points, backgrounds and theme format schemes;
media clips and posters, 3-D models, OLE/SmartArt previews and node pictures,
picture bullets, hyperlink, transition and animation sounds, embedded fonts.
slide.reuse now lists and remaps media through it, uses get() for slides, and
keeps a linked media item's link when copying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:35:50 -04:00
Brandon Thomas 081c9fdc5e Merge the landing branch into land-r7
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	apps/deckcraft-cli/tests/cli.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/textures.rs
2026-10-14 15:34:15 -04:00
Brandon Thomas a5a3137e80 Merge PR #509 from NikitkaRa: Slides: reuse slides from another presentation
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/pptx/tests/roundtrip.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 15:32:18 -04:00
Brandon ThomasandClaude Opus 5.5 398d34c737 Fix up merged PRs: cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:30:29 -04:00
Brandon ThomasandClaude Opus 5.5 f403c65c94 Fix up #355: kept chart parts get fresh names; chartex keeps its workbook
A chart's kept parts were written under the names the source file gave them,
so a relationship to ppt/media/image1.png (or any name the writer generates)
produced a second zip entry with that name on save. Kept parts are now written
as ppt/<folder>/keptN.<ext> with a content-type override each, the chart's and
the kept parts' own relationships are rewritten to the new names (relationships
to parts that were not kept are dropped), and the package writer skips any
second entry under a name it already wrote. externalData/userShapes are dropped
only when their relationship is not written back.

Office 2016 chartex charts (added on main since the PR) capture and write back
their kept parts the same way; the plain-chart fallback carries none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:30:16 -04:00
Brandon ThomasandClaude Opus 5.5 30107bdb31 Fix up #321: drop the empty-body layout_for change main's caret_line already covers
The caret's colour (caret_color) is kept and used at main's caret draw site; the
placeholder-prompt hiding is kept too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:29:48 -04:00
Brandon Thomas bdd9898d6f Merge PR #321 from xc4pr: Text: the caret shows on dark slides and in empty placeholders
# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/textures.rs
2026-10-14 15:29:17 -04:00
Brandon ThomasandClaude Opus 5.5 0dfc9ccf6f Fix up #247: route main's other file access through the checked io layer
design.browseTheme reads its path with io::read_file, and File › Info sizes the file with
io::file_len (which checks the read root), so commands_do_not_touch_the_file_system_themselves
passes. spell.rs is allow-listed beside recovery.rs: it touches only the app's own dictionary
folders. main's render_slide MCP tool refuses a `path` argument (#371), so the root tests
render to a file through call_app ui.render instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:27:46 -04:00
Brandon Thomas 9fc910886c Merge PR #247 from isaac-levine: MCP: confine file access with --automation-read-root and --automation-write-root
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
2026-10-14 15:25:09 -04:00
Brandon Thomas 34f4c1b1df Merge the older community PRs integrated on land-r6 (#204 #243 #291 #345 #258 #303 #230 #168 #177 #692 #522 #466)
# Conflicts:
#	crates/ui-egui/src/canvas.rs
2026-10-14 15:24:58 -04:00
Brandon Thomas 6b62aff539 Merge PR #225 from xmaks82: Render: an unfilled shape's glow follows its line
# Conflicts:
#	crates/render/src/lib.rs
2026-10-14 15:23:36 -04:00
Brandon ThomasandClaude Opus 5.5 d66833b7c2 Fix up merged PRs: land-r6's changes fitted to the landing branch
- review.thesaurus's params doc parses under describe's grammar; describe's edit.copy test
  expects the landing branch's {text, html?} answer.
- The Language dialog test picks the landing branch's "Français (France)" entry; the Format
  Background test allows the pane's title to appear twice.
- Progress docs rebuilt from the landing branch's versions with land-r6's rows applied on top,
  so the union merge no longer drops the pen-pressure, ui.pointer and ui.key rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:23:16 -04:00
Brandon Thomas b6a4343ec5 Merge PR #355 from NikitkaRa: Charts: keep the embedded workbook when a chart is saved unchanged (#335)
# Conflicts:
#	contributors/people.toml
#	crates/pptx/src/opc.rs
#	crates/pptx/src/read/chart.rs
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/chart.rs
2026-10-14 15:23:07 -04:00
Brandon ThomasandClaude Opus 5.5 708a22c51f Fix up merged PRs: Opaque shapes in tests carry #209's new parts field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:22:27 -04:00
Brandon Thomas 49d215f07f Merge PR #209 from justinjohn0306: PPTX: SmartArt and OLE objects we can't draw keep their parts on save (#203)
# Conflicts:
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/tests/roundtrip.rs
2026-10-14 15:20:41 -04:00
Brandon ThomasandClaude Opus 5.5 4b6f307ebb Fix up #169: auto RTL lives only in the engine and right-aligns only unaligned text
Drop the layout-time direction detection in crates/text (and its test); the engine's
promote_rtl_if_needed sets align Right only when neither the paragraph nor what it
inherits (list styles, layout, master) resolves an alignment. right_to_left_paragraphs
types its original Arabic text again, after an explicit LTR so the toggle it tests
starts from left-to-right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:19:02 -04:00
Brandon ThomasandClaude Opus 5.5 b9851d9329 Merge PR #611 from NikitkaRa: Draw: pen pressure scales ink width
Fix-ups in the merge (review): uniform-width and highlighter strokes are drawn as one path (on
the slide and in the live preview) so see-through ink doesn't darken where segments overlap;
segment pairs are destructured instead of indexed; tests that build pointer events set the new
pressure field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:18:04 -04:00
Brandon Thomas 91eb9d8884 Merge the landing branch into land-r6
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/ribbon.rs
#	crates/ui-egui/src/status.rs
#	crates/ui-egui/src/textures.rs
#	crates/ui-egui/src/thumbs.rs
2026-10-14 15:14:21 -04:00
Brandon Thomas bd005c4626 Merge PR #169 from Hevarh1: Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew 2026-10-14 15:14:09 -04:00
Brandon ThomasandClaude Opus 5.5 621efd0290 Merge PR #673 from NikitkaRa: Charts: error bars
Fix-ups in the merge: error-bar statistics are computed once per series (was once per point,
quadratic on long series); reading custom amounts uses the chart-wide point budget (#480).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:14:06 -04:00
Brandon Thomas 3d0c19af45 Merge PR #151 from MarekSpodBiedry: Charts: a missing varyColors varies single-series bar colours by point (#143)
# Conflicts:
#	crates/pptx/src/read/chart.rs
#	crates/render/src/chart.rs
#	crates/render/src/tests.rs
2026-10-14 15:13:35 -04:00
Brandon Thomas ac7fa9047b Merge PR #150 from MarekSpodBiedry: Charts: a single-series chart without an explicit title shows the series name as its automatic title (#142)
# Conflicts:
#	crates/pptx/src/read/chart.rs
2026-10-14 15:11:27 -04:00
Brandon ThomasandClaude Opus 5.5 b9f112a6e4 Merge PR #670 from NikitkaRa: Format: never save a .deckcraft that cannot be reopened over a NaN
Fix-up in the merge (review): reading older files that hold null numbers re-parses at most
256 MB in all and slices without indexing, so a hostile file can't hang the open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:11:25 -04:00
Brandon ThomasandClaude Opus 5.5 a0facd60af Fix up merged PRs: cargo fmt, and the write_file tests may unwrap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:10:48 -04:00
Brandon ThomasandClaude Opus 5.5 6d23d5b5b5 Fix up #139: the Tab test turns off AutoCorrect, which main now capitalises with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:09:26 -04:00
Brandon Thomas bad8e9d1dd Merge PR #139 from alexanderalber: Text: typing goes on after Tab in a text box (#78)
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 15:08:45 -04:00
Brandon Thomas b56d5792b0 Merge the format, canvas and dialog PRs integrated on land-r4 (#567 #569 #630 #637 #536 #559 #519 #506 #549 #556 #525)
# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 15:06:35 -04:00
Brandon ThomasandClaude Opus 5.5 c448a3dba9 Fix up merged PRs: progress docs without the rows the union merges duplicated
Stale status lines and table rows from the older PRs are folded into main's current ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:05:32 -04:00
Brandon Thomas e80d3f4aed Merge PR #137 from alexanderalber: Morph: a picture cropped differently on the next slide morphs its crop (#118)
# Conflicts:
#	crates/anim/src/lib.rs
#	crates/anim/src/tests.rs
2026-10-14 15:05:27 -04:00
Brandon ThomasandClaude Opus 5.5 2e727a3ccf Merge PR #492 from NikitkaRa: Shapes: smooth a rectangle's corners into a squircle (#286)
Fix-up in the merge (review): shape.smoothCorners re-smooths a custom outline only when it is a
squircle it drew with the given radius, so a freeform is never replaced by a rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:05:10 -04:00
Brandon Thomas c40b2d53ee Merge PR #136 from alexanderalber: Morph: text boxes without a partner fade in place instead of moving (#119) 2026-10-14 15:04:43 -04:00
Brandon ThomasandClaude Opus 5.5 fc118f1710 Fix up #88: an image paste needs Command/Ctrl held on the V release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:04:42 -04:00
Brandon ThomasandClaude Opus 5.5 320e3a971a Fix up merged PRs: one Editing group on Home, fmt, docs after the format/canvas/dialog merges
- Home: #556's Find and Replace join main's Replace Fonts… in one Editing group with the
  Selection Pane, as PowerPoint lays it out; the separate group pushed Replace Fonts… out of
  reach at 1400 px. The Replace Fonts test picks the dialog's Replace button, not the ribbon's.
- cargo fmt.
- docs: parity summary regenerated with `cargo xtask parity`; ui-parity's ruler row says tab
  stops are set on the ruler (#567/#569) as well as in the Tabs dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:04:16 -04:00
Brandon Thomas 1d7ba53cf9 Merge PR #466 from MarekSpodBiedry: File: save through a unique, exclusively created temporary file (#391)
# Conflicts:
#	crates/engine/src/cmd/file.rs
2026-10-14 15:03:00 -04:00
Brandon ThomasandClaude Opus 5.5 330fc16c8c Fix up merged PRs: zoom anchors on the unsnapped slide rect; zoom tests allow the pixel-grid snap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:02:43 -04:00
Brandon Thomas 1481c2d3f0 Merge PR #88 from MarekSpodBiedry: Paste: Ctrl+V pastes an image-only clipboard as a picture (#53)
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 15:00:34 -04:00
Brandon ThomasandClaude Opus 5.5 a4ea5b66db Fix up #87: the web PNG/JPEG export path in SlideApp::export_as downloads every slide
Port web_image_files into main's export_as (keeping its DPI tags) and drop the PR's
old dialogs.rs export branch, which main no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:59:50 -04:00
Brandon Thomas f800acee47 Merge the startup, clipboard, animation-preview and UI PRs integrated on land-r5 (#644 #652 #655 #702 #571 #539 #674 #648 #697 #479 #369 #368)
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:58:48 -04:00
Brandon Thomas 95cb3be9e6 Merge PR #522 from NikitkaRa: UI: keep editor slides on the Retina pixel grid
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/sorter.rs
#	crates/ui-egui/src/textures.rs
2026-10-14 14:58:19 -04:00
Brandon Thomas 8700a2da92 Merge PR #87 from MarekSpodBiedry: Export: PNG images (all slides) in the browser downloads every slide (#64)
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:57:55 -04:00
Brandon ThomasandClaude Opus 5.5 afc387cdc5 Merge PR #468 from NikitkaRa: Media: a failed or stalled audio output hands the clock back to wall time (#417)
Fix-up in the merge (review): a just-started stream gets 5 s for its first callback before it
counts as stalled (slow Bluetooth devices), and the failure count resets once a restarted
stream delivers, so one bad start no longer silences media for the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:54 -04:00
Brandon ThomasandClaude Opus 5.5 0e51b7b1fc Fix up #67: use get_mut in the outline-morph cache instead of indexing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:31 -04:00
Brandon Thomas adf273bf4d Merge PR #67 from alexanderalber: Morph: align an outline morph once per transition, not every frame
# Conflicts:
#	crates/render/src/lib.rs
2026-10-14 14:56:45 -04:00
Brandon Thomas f8949f28cd Merge PR #692 from NikitkaRa: UI: Format Background picture fill sets transparency and tiling
# Conflicts:
#	crates/engine/src/cmd/design.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 14:55:58 -04:00
Brandon ThomasandClaude Opus 5.5 ed13dd6158 Fix up merged PRs: GIF export goes through main's export path
The Export dialog's GIF choice runs through SlideApp::export_as (desktop and web download),
.gif is a writable output extension for convert/run, and the CLI help pages describe GIF output
and --export-params. A file.export `slides` range string ("1-3,5") works for GIF as for PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:35 -04:00
Brandon Thomas 3af36720fd Merge PR #525 from NikitkaRa: Review: compare with a revised copy and accept its slides
# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 14:53:58 -04:00
Brandon Thomas b2b0e82359 Merge PR #556 from NikitkaRa: Text: Find and Replace across slides, tables and notes
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:53:21 -04:00
Brandon Thomas c354f02279 Merge PR #177 from Mathiasko: Export: animated GIF of the deck (Export… dialog, CLI, MCP) (#171)
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
#	crates/mcp/src/tests.rs
#	crates/mcp/src/tools.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:52:34 -04:00
Brandon Thomas 1e6d9a2888 Merge PR #549 from NikitkaRa: Review: Thesaurus pane with a built-in English word list
# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 14:51:35 -04:00
Brandon Thomas 143a842d1c Merge PR #506 from NikitkaRa: Format Shape: picture or texture fill options
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 14:51:02 -04:00
Brandon ThomasandClaude Opus 5.5 69e212e3af Fix up merged PRs: the IME notes test reads the formatted draft's text; drop an unused import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:50:49 -04:00
Brandon ThomasandClaude Opus 5.5 e6862f18ae Fix up merged PRs: WordArt warp handles on main's warp catalog and hover hit list
- #519's handle test iterated the old `ALL` preset list; main has `CATALOG` now.
- The hover hit-test match lists every Hit variant; add #519's Hit::Warp.
- Drop the duplicate "tabs" dialog title that #569 and #637 both added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:49:10 -04:00
Brandon Thomas 86c5559f24 Merge PR #632 from NikitkaRa: Spelling: notes right-click menu, Review > Language (Do Not Check Spelling), repeated words
# Conflicts:
#	crates/engine/src/cmd/format.rs
2026-10-14 14:48:54 -04:00
Brandon Thomas b267a921cd Merge PR #519 from NikitkaRa: UI: WordArt adjust handles drag a text warp
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
#	crates/geom/src/warp.rs
2026-10-14 14:46:54 -04:00
Brandon ThomasandClaude Opus 5.5 560928af94 Fix up merged PRs: cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:46:41 -04:00
Brandon ThomasandClaude Opus 5.5 748b83a284 Fix up merged PRs: one re-export of the table cell helpers (#618 and #626 both added it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:46:09 -04:00
Brandon Thomas e9923c6c42 Merge PR #368 from NikitkaRa: A tabbed slide body is an outline, and --help exits (#360)
# Conflicts:
#	apps/deckcraft/src/main.rs
#	crates/engine/src/cmd/slide.rs
#	crates/engine/src/cmd/text.rs
2026-10-14 14:45:23 -04:00
Brandon Thomas 3723b36ff2 Merge PR #168 from Mathiasko: Notes: speaker notes show their formatting and keep it when edited
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/model/src/edit.rs
#	crates/text/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/sorter.rs
#	crates/ui-egui/src/status.rs
2026-10-14 14:45:13 -04:00
Brandon ThomasandClaude Opus 5.5 e90e265453 Fix up merged PRs: Grid Options guides follow #559, and its dialog test finds its own Spacing
- Turning guides on in Grid Options (#536) now adds the two centre guides to a presentation
  without any, as View › Guides does since #559 stopped drawing fixed centre lines.
- The Grid Options dialog test looked for any "Spacing" label; main's ribbon has Line Spacing
  too, so it looks for "Spacing (pt)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:12 -04:00
Brandon Thomas 822c8f44b3 Merge PR #369 from NikitkaRa: Mark the current transition effect option (#365)
# Conflicts:
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:44:56 -04:00
Brandon Thomas b815568fd7 Merge PR #626 from NikitkaRa: Spelling: squiggles in table cells and speaker notes; UI-thread checking on the web
# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/ui-egui/src/status.rs
2026-10-14 14:44:48 -04:00
Brandon ThomasandClaude Opus 5.5 526e31d1f7 Fix up merged PRs: a picked file remembers main's Handout and Notes Master views (#479)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:44:40 -04:00
Brandon Thomas eff4c02efa Merge PR #615 from NikitkaRa: Spelling: squiggles while typing, Add to Dictionary, per-run languages, compounds
# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/ui-egui/src/canvas.rs
2026-10-14 14:44:12 -04:00
Магда М.Г. 8d68b2fe9d Render: an unfilled shape's glow follows its line
A glow always filled the shape's whole silhouette, so a frame with no fill
(a rounded-rectangle border with a soft glow, common in corporate decks)
came out as a solid tinted panel over the slide. PowerPoint glows such a
shape along its outline only and leaves the inside clear; a filled shape's
or a picture's glow is unchanged. The line glow uses the same width and
blur as the outline part of a filled shape's glow.

Checked against PowerPoint's own PDF export of 31 real decks (597
slides): 66 slides closer to PowerPoint, none further; the frames' slides
go from a mean pixel difference of ~29 to ~2.5-8 (of 255), and the 90th
percentile over all slides from 12.9 to 10.0.
2026-10-14 20:44:01 +02:00
Brandon Thomas 5fbdb6c79e Merge PR #479 from NikitkaRa: Web: keep a picked file on the document and slide that asked for it
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 14:43:55 -04:00
Brandon ThomasandClaude Opus 5.5 740a57ddba Fix up merged PRs: table borders under a right-click select the table; the cell caret is drawn once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:43:36 -04:00
Brandon Thomas 4416bc170a Merge PR #697 from NikitkaRa: UI: Format Shape fill transparency follows the selection
# Conflicts:
#	crates/ui-egui/src/panes.rs
2026-10-14 14:43:08 -04:00
Brandon Thomas bd607f333a Merge PR #648 from NikitkaRa: Canvas: a trackpad swipe turns the page
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:42:43 -04:00
Brandon ThomasandClaude Opus 5.5 367b079d64 Fix up #609: spelling language tags can't leave the dictionary folders; long words get no suggestions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:42:15 -04:00
Brandon Thomas 6f38c958b6 Merge PR #674 from NikitkaRa: Canvas: a two-finger twist rotates the selection
# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:42:12 -04:00
Brandon Thomas b3ec4013e6 Merge PR #230 from pierrevbc: Tables: caret in the edited cell, click-to-place, drag borders to resize
# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
2026-10-14 14:41:34 -04:00
Brandon Thomas 5ed2f808e3 Merge PR #539 from NikitkaRa: Draw: Ink to Shape converts pen strokes into shapes
# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:41:19 -04:00
Brandon Thomas ec44671a81 Merge PR #559 from NikitkaRa: View: movable drawing guides saved with the presentation
# Conflicts:
#	crates/engine/src/tools.rs
#	crates/model/src/defaults.rs
#	crates/model/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:41:12 -04:00
Brandon Thomas 79b3e3693f Merge PR #571 from NikitkaRa: UI: preview slide animations on the editor canvas
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/show.rs
2026-10-14 14:40:51 -04:00
Brandon Thomas e1a92e37c6 Merge PR #609 from NikitkaRa: Review: spelling with Hunspell dictionaries, suggestions, Change and Ignore All
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:40:49 -04:00
Brandon ThomasandClaude Opus 5.5 2173cf9569 Fix up merged PRs: Draw Table and Table Eraser sit in the Table Design tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:40:38 -04:00
Brandon ThomasandClaude Opus 5.5 7dcfe0f989 Fix up merged PRs: a textless shape still copies as the objects marker so Ctrl+V pastes it (#182), not as a picture (#702)
With only an image on the system clipboard egui-winit drops Ctrl+V, so #702's
picture copy of a textless shape undid main's #182 fix. Text keeps #702's HTML,
and empty plain text falls back to the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:40:18 -04:00
Brandon ThomasandClaude Opus 5.5 3b28496c36 Fix up merged PRs: main's newer params docs fit describe's grammar; help pages for bench and render-transition
file.export's slide range and transition.sound's keys parse as parameters, and the subcommands
main added since #303 have their own help page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:40:09 -04:00
Brandon Thomas 38e95d8c15 Merge PR #702 from NikitkaRa: Clipboard: rich text and pictures for Pages and Keynote
# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:39:36 -04:00
Brandon ThomasandClaude Opus 5.5 1659e06565 Fix up merged PRs: Draw Table's table lookup uses the checked shape id param (#544 after #97); its test no longer writes to /tmp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:39:10 -04:00
Brandon Thomas 2d4c4e75d8 Merge PR #536 from NikitkaRa: UI: Grid Options sets spacing, snap and the on-screen grid
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:39:01 -04:00
Brandon ThomasandClaude Opus 5.5 7acb9faad1 Fix up merged PRs: one format.tabs and one Tabs dialog for #567/#569 and #637
#569 and #637 each added a `format.tabs` command and a Tabs dialog. Keep one of each:
- format.tabs takes #567/#569's add/move/remove/clear/set plus #637's strict `set`
  (unknown alignment or non-object entries are errors, stops within 0.05 pt collapse,
  last wins) and opens the Tabs dialog when called with no fields, as #637 did.
  A defaultTab-only call no longer rewrites the stops.
- The Tabs dialog is #569's; it now seeds from the dialog's params when Paragraph (or
  format.tabs) opened it, and on OK hands the applied stops back to Paragraph.
- Tests adapted: an empty format.tabs opens the dialog; the Paragraph → Tabs test sets
  stops through #569's dialog fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:38:03 -04:00
Brandon Thomas 23eb82c02a Merge PR #544 from NikitkaRa: Tables: Draw Table pencil and Table Eraser
# Conflicts:
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:38:00 -04:00
Brandon Thomas c3489d6a99 Merge PR #655 from NikitkaRa: Show: presenter view keeps a portrait Next slide inside its column
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 14:37:36 -04:00
Brandon ThomasandClaude Opus 5.5 2a410e54fa Fix up merged PRs: main's newer textures (document window, SmartArt previews, show grid) go through textures::upload so they stay within the GPU max texture side (#652)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:37:30 -04:00
Brandon Thomas e225e4eec1 Merge PR #652 from NikitkaRa: UI: keep every texture within the GPU max texture side
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 14:37:12 -04:00
Brandon Thomas 8587be6af9 Merge PR #542 from NikitkaRa: Design: Variants gallery with four colour variations of the theme
# Conflicts:
#	crates/engine/src/cmd/design.rs
#	crates/engine/src/tests.rs
2026-10-14 14:37:07 -04:00
Brandon Thomas b590cb0b0b Merge PR #303 from hoblin: deckcraft-cli help explains the CLI to an agent
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	crates/engine/src/cmd/design.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/cmd/format.rs
#	crates/engine/src/tests.rs
2026-10-14 14:36:13 -04:00
Магда М.Г. 8c3404fc87 Fonts: falling back to the default face no longer deadlocks
cascade's last fallback searched the loaded faces and, finding none that
draws the cluster, called `first` while the search's read guard was still
alive (a temporary in the match arm lives to the end of the `let`).
`first` looks up the default family, which loads it through add_fonts,
and add_fonts takes the faces' write lock even when there is nothing to
add: the thread waited on itself, and every other thread then waited on
the loading lock it held.

It shows when a run's chain is empty and nothing loaded draws the
character before the default family has been looked up, so it depends on
which test (or document) gets there first: deckcraft-text's tests hung in
4 of 10 parallel runs here (all threads parked in system_fallback /
try_family, one in add_fonts' write), and in 0 of 15 with this change.

The search now finishes before `first` runs. New test
falling_back_to_the_default_face_does_not_deadlock reaches that path on a
fresh database; it times out on main and passes with the fix.
2026-10-14 20:35:43 +02:00
Brandon Thomas 89da88e374 Merge PR #258 from RCShoemaker: fix(cli): render refuses an unknown option instead of writing it as a file (#235)
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	contributors/people.toml
2026-10-14 14:34:57 -04:00
Brandon Thomas e406e11db2 Merge PR #444 from MarekSpodBiedry: CLI: render-transition refuses --t values that would share one file name (#423)
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
2026-10-14 14:33:40 -04:00
Brandon Thomas 1c150c3c31 Merge PR #345 from marcoarnulfo: Review: the Language dialog marks the selected text with the language you pick
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:31:22 -04:00
Brandon ThomasandClaude Opus 5.5 2dc22843e4 Fix up merged PRs: the missing-recent test reads main's toast, which now carries open errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:31:11 -04:00
Brandon ThomasandClaude Opus 5.5 8ee597216d Fix up landed PRs from review
- #178: ToUnicode ranges looked up without indexing
- #465: frame_num gaps are concealed (bounded) even when the SPS forbids them; cut streams keep playing
- #471: control request lines up to 256 MB so picture/media inserts still fit
- #473: a merge that loses its only row or column is removed, with regression tests
- #521: AutoCorrect no longer rewrites real words (cant, wont, im)
- #654: inherit_placeholder_geometry's doc comment back on its function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:31:01 -04:00
Brandon Thomas 12df77ed19 Merge PR #637 from NikitkaRa: UI: Paragraph dialog sets alignment, indents and spacing
# Conflicts:
#	crates/engine/src/cmd/format.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:30:55 -04:00
Brandon Thomas be445fb083 Merge PR #630 from NikitkaRa: UI: Font dialog sets character formatting in one step
# Conflicts:
#	crates/engine/src/cmd/format.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:27:38 -04:00
Brandon Thomas f6cf99bf87 Merge PR #291 from albayazid: Start screen: a missing Recent file is marked, its error shows, and entries can be removed (#172)
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 14:27:28 -04:00
Brandon ThomasandClaude Opus 5.5 0609ca01cb Fix up merged PRs: right-click on the edited text box's frame selects that box
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:27:15 -04:00
Brandon Thomas f0dbc08c09 Merge PR #569 from NikitkaRa: Text: Tabs dialog and the ruler's tab selector (stacked on #567)
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:27:04 -04:00
Brandon Thomas 80d3ad5f24 Merge PR #243 from pierrevbc: Canvas: drag the frame of a text box being edited to move it
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
2026-10-14 14:26:59 -04:00
Brandon Thomas aec4043ccd Merge PR #567 from NikitkaRa: View: indent and tab markers on the ruler
# Conflicts:
#	crates/engine/src/cmd/format.rs
#	crates/ui-egui/src/canvas.rs
2026-10-14 14:26:32 -04:00
Brandon Thomas 8fde5f31e7 Merge PR #204 from justinjohn0306: Pictures: Change Picture replaces the selected picture (#115)
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:26:16 -04:00
Brandon Thomas a0d97fa931 Merge PR #644 from NikitkaRa: Startup: survive damaged settings, font folders, recovery folders and small texture limits
# Conflicts:
#	crates/engine/src/recovery.rs
#	crates/fonts/src/fontdb.rs
#	crates/fonts/src/lib.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 14:24:45 -04:00
Brandon Thomas d498633faf Merge PR #654 from NikitkaRa: PPTX: shape text anchors at the top when the file gives no anchor; write ctr for centred shapes 2026-10-14 14:24:17 -04:00
Brandon Thomas 71c3848eff Merge PR #521 from NikitkaRa: Text: AutoCorrect options, case rules and your own entries
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 14:24:16 -04:00
Brandon Thomas 42b6e8e871 Merge PR #473 from NikitkaRa: Table: inserting and deleting rows and columns keeps merges consistent (#408) 2026-10-14 14:24:15 -04:00
Brandon Thomas fca521ac8c Merge PR #471 from NikitkaRa: Control server: bound connections, line length, queue and socket timeouts (#418) 2026-10-14 14:24:15 -04:00
Brandon Thomas 3335ee0feb Merge PR #465 from NikitkaRa: H.264: bound frame_num gap work and reject gaps the SPS forbids (#427)
# Conflicts:
#	crates/h264/src/synth_tests.rs
2026-10-14 14:24:15 -04:00
Brandon Thomas 31390e5e21 Merge PR #178 from MarekSpodBiedry: PDF: map each glyph to its shaping cluster's text in ToUnicode (#164)
# Conflicts:
#	crates/pdf/src/tests.rs
2026-10-14 14:24:15 -04:00
Brandon Thomas 0c2c433ade Merge the chart and EMF+ metafile PRs integrated on land-r2 (#532 #537 #591 #594 #598 #602 #603 #638 #640 #662 #666 #669 #690 #582 #588 #633 #635)
# Conflicts:
#	crates/pptx/src/write/mod.rs
#	crates/pptx/tests/roundtrip.rs
2026-10-14 14:17:35 -04:00
Brandon ThomasandClaude Opus 5.5 a85024558d Fix up merged PRs: Quick Access Toolbar defaults keep main's Export button and custom-show start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:16:59 -04:00
Brandon Thomas f6661ed15e Merge PR #563 from NikitkaRa: UI: customizable Quick Access Toolbar
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:16:22 -04:00
Brandon ThomasandClaude Opus 5.5 b2b378a3f0 Fix up merged PRs: dedupe doc rows left by the union merges
One EMF / WMF parity row naming both GDI clip regions and EMF+ playback; drop
the stale Tables, Charts, chartex and SmartArt rows that came back with the
chartex PRs; one SVG-gap sentence; one charts-gap paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:13:50 -04:00
Brandon Thomas ea12297822 Merge PR #223 from marcoarnulfo: Title bar: on macOS the window buttons sit centred in it (#71)
# Conflicts:
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:13:29 -04:00
Brandon Thomas 4eae520beb Merge PR #484 from NikitkaRa: Video: check the whole picture store against a memory budget (#398)
# Conflicts:
#	crates/av1/src/decoder.rs
2026-10-14 14:12:46 -04:00
Brandon Thomas 16355053e1 Merge PR #481 from NikitkaRa: AV1: reject duplicate film-grain scaling points; a failed job still publishes (#394)
# Conflicts:
#	crates/av1/src/decoder.rs
2026-10-14 14:12:22 -04:00
Brandon Thomas 8ea2cb0871 Merge PR #361 from NikitkaRa: CLI: say that render --slide counts from 0 (#359)
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
2026-10-14 14:12:10 -04:00
Brandon Thomas 3a8927e3f3 Merge PR #691 from NikitkaRa: UI: Trim Media dialog follows the selection
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 14:11:53 -04:00
Brandon ThomasandClaude Opus 5.5 0c3f4eee63 Fix up merged PRs: Slide Master Preserve/placeholders know the notes and handout master views
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:07:31 -04:00
Brandon Thomas a4fa978903 Merge PR #703 from NikitkaRa: UI: Slide Master Preserve and Title or Footers placeholders
# Conflicts:
#	crates/engine/src/cmd/design.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:06:42 -04:00
Brandon ThomasandClaude Opus 5.5 31e22977f7 Fix up merged PRs: the palette test skips UI commands that need a value (#440 hides them)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:05:00 -04:00
Brandon Thomas 7266b30018 Merge PR #698 from NikitkaRa: UI: Design Effects applies a theme effect scheme
# Conflicts:
#	crates/engine/src/cmd/design.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 14:04:24 -04:00
Brandon Thomas 2009242055 Merge PR #684 from NikitkaRa: Export: a PNG or JPEG slide records 144 dpi at the default 2× size
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/tests.rs
2026-10-14 14:02:59 -04:00
Brandon Thomas 1e80cd5547 Merge PR #440 from NikitkaRa: Palette: skip commands that need a value
# Conflicts:
#	crates/ui-egui/src/menus.rs
2026-10-14 14:01:08 -04:00
Brandon Thomas 749656605c Merge PR #635 from NikitkaRa: Metafile: EMF+ Clear, and the EMF+ record coverage written down 2026-10-14 13:57:48 -04:00
Brandon Thomas 1360b283f3 Merge PR #633 from NikitkaRa: Metafile: EMF+ cardinal curves and region fills
# Conflicts:
#	crates/render/src/tests.rs
2026-10-14 13:57:48 -04:00
Brandon ThomasandClaude Opus 5.5 389c4dd5d0 Fix up merged PRs: #588's polygon clip and nested metafiles with clip regions
Move #588's clip.rs changes into cliprect.rs, carry main's per-item clip
region through the polygon clipper, and build nested-metafile items with the
region field main added (the nested picture's own regions are not carried
over; each item takes the canvas's current clip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:56:56 -04:00
Brandon Thomas b3c9cc099c Merge the PPTX writer, notes/handout master, PDF and slide-show PRs integrated on land-r3 (#518 #531 #535 #533 #572 #573 #577 #580 #583 #592 #685 #528 #483 #490 #441 #671 #679 #649 #656 #660 #664 #224)
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/model/src/lib.rs
#	crates/pptx/src/write/mod.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/thumbs.rs
2026-10-14 13:56:41 -04:00
Brandon Thomas 4d7406be10 Merge PR #588 from NikitkaRa: Metafile: EMF+ arcs, convex clip paths, wrapped strings, nested metafiles
# Conflicts:
#	crates/metafile/src/canvas.rs
#	crates/metafile/src/clip.rs
#	crates/metafile/src/emfplus.rs
2026-10-14 13:56:02 -04:00
Brandon Thomas 126029db40 Merge PR #254 from xmaks82: Text: honour spcFirstLastPara (space before the first paragraph)
# Conflicts:
#	crates/pptx/src/read/dml.rs
#	crates/text/src/lib.rs
#	crates/text/src/tests.rs
2026-10-14 13:55:43 -04:00
Brandon ThomasandClaude Opus 5.5 3450180f19 Fix up merged PRs: EMF+ clipping alongside GDI clip regions
main added its own clip.rs (GDI rectangle regions carried on every drawn
item); #582 added a clip.rs that cuts drawing geometrically to the EMF+ clip
rectangle. Keep main's module, move #582's to cliprect.rs, keep the EMF+
rectangle on the canvas as plus_clip, and carry the region field through the
geometric clipper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:38 -04:00
Brandon Thomas eab5686393 Merge PR #582 from NikitkaRa: Metafile: EMF+ images, text, gradient brushes and clipping
# Conflicts:
#	crates/metafile/src/canvas.rs
#	crates/metafile/src/clip.rs
2026-10-14 13:54:59 -04:00
Brandon Thomas 912598fa46 Merge PR #675 from NikitkaRa: View: Zoom dialog applies a percent or Fit
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:54:33 -04:00
Brandon Thomas ad7eb45abe Merge PR #690 from NikitkaRa: Charts: a chart frame with no width no longer panics
# Conflicts:
#	crates/render/src/chart.rs
#	crates/render/src/tests.rs
2026-10-14 13:53:49 -04:00
Brandon ThomasandClaude Opus 5.5 5c7e9fc6c3 Format the merged PRs (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:53:13 -04:00
Brandon ThomasandClaude Opus 5.5 bb3627f954 Fix up merged PRs: minor gridlines on a logarithmic axis
#666's minor gridlines stepped in value units, but #669 scales a log axis in
exponents of its base; map each minor step back through the base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:53:06 -04:00
Brandon ThomasandClaude Opus 5.5 f9c1000e35 Fix up merged PRs: rustfmt; drop doc rows the union merge left twice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:51:53 -04:00
Brandon ThomasandClaude Opus 5.5 0ac222a543 Fix up merged PRs: split ribbon buttons carry their label once (#512's label predates main's)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:51:49 -04:00
Brandon Thomas 6d1b01b73d Merge PR #669 from NikitkaRa: Charts: logarithmic value axes
# Conflicts:
#	crates/pptx/src/read/chart.rs
#	crates/render/src/chart.rs
2026-10-14 13:51:30 -04:00
Brandon Thomas 808087e6a5 Merge PR #666 from NikitkaRa: Charts: value-axis minor gridlines
# Conflicts:
#	crates/pptx/src/read/chart.rs
2026-10-14 13:50:53 -04:00
Brandon ThomasandClaude Opus 5.5 5089d615bf Fix up merged PRs: scatter trendlines follow the value x axis
#662 gives scatter and bubble charts one x scale over every series; #603's
trendlines still mapped x over each series' own extent, so a fit drifted off
its points. Use the chart's x scale where there is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:50:37 -04:00
Brandon ThomasandClaude Opus 5.5 6902c4bf92 Fix up merged PRs: every presenter-view button uses the light-on-dark style (#221)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:50:27 -04:00
Brandon Thomas d2bf428362 Merge PR #224 from justinjohn0306: Notes and Presenter View: long notes scroll, presenter controls show (#221)
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:50:20 -04:00
Brandon Thomas 256ad7057c Merge PR #662 from NikitkaRa: Charts: scatter and bubble charts get a value x axis
# Conflicts:
#	crates/render/src/chart.rs
2026-10-14 13:49:59 -04:00
Brandon Thomas bc62c58b17 Merge PR #664 from NikitkaRa: Show: show the slide number while typing it
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:49:49 -04:00
Brandon ThomasandClaude Opus 5.5 b61cf33836 Fix up merged PRs: custom-show links carry 'returns'; prefetch follows the show's own order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:49:36 -04:00
Brandon ThomasandClaude Opus 5.5 0f28ac96ed Fix up merged PRs: reversed category axes and trendline forecasts agree
#640 orders category slots bottom-to-top on horizontal bars (or reversed via
c:orientation) while #603 pads the axis for trendline forecasts. Slots now
include the forecast padding on the right side, and trendlines map their
category positions through the same slot order as the bars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:49:35 -04:00
Brandon Thomas e74015490b Merge PR #660 from NikitkaRa: Show: play a custom show
# Conflicts:
#	crates/anim/src/show.rs
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/show.rs
2026-10-14 13:49:10 -04:00
Brandon Thomas 3e89fbbaa6 Merge PR #656 from NikitkaRa: Presenter view: pause and reset the timer, and notes size
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:48:31 -04:00
Brandon Thomas 266da52dd9 Merge PR #640 from NikitkaRa: Charts: category axis orientation; horizontal bars list the first category at the bottom
# Conflicts:
#	crates/pptx/src/read/mod.rs
#	crates/pptx/src/write/chart.rs
#	crates/render/src/chart.rs
2026-10-14 13:48:09 -04:00
Brandon Thomas 6c605be582 Merge PR #649 from NikitkaRa: Show: See All Slides and zoom
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:47:54 -04:00
Brandon Thomas 593f55dfa1 Merge PR #638 from NikitkaRa: Charts: bar widths, column/bar point fills, smoothed lines and chart text colour as the file sets them
# Conflicts:
#	crates/pptx/src/read/chart.rs
#	crates/render/src/chart.rs
2026-10-14 13:47:34 -04:00
Brandon ThomasandClaude Opus 5.5 32a49e8bf6 Fix up merged PRs: a trendline caption in a tiny chart no longer panics
f64::clamp panics when the plot is narrower than the caption's minimum width;
use main's clamp_soft, as the rest of the chart code now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:47:08 -04:00
Brandon Thomas 8f0640a7b2 Merge PR #603 from NikitkaRa: Render: draw chart trendlines
# Conflicts:
#	crates/render/src/chart.rs
2026-10-14 13:47:08 -04:00
Brandon Thomas d6d7e91c47 Merge PR #679 from NikitkaRa: Show: Show Media Controls hides the playback bar
# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/show.rs
2026-10-14 13:46:55 -04:00
Brandon Thomas d3ebe57a02 Merge PR #671 from NikitkaRa: Show: a first animation set to With/After Previous plays when the slide appears
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:46:32 -04:00
Brandon Thomas ee991563c9 Merge PR #441 from NikitkaRa: Show: a laser pointer that leaves no ink
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:45:58 -04:00
Brandon ThomasandClaude Opus 5.5 0a470c3b1f Fix up merged PRs: prefetch GIF check passes the document uid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:41 -04:00
Brandon Thomas 23ac41f191 Merge PR #490 from NikitkaRa: Show: render the next slide in the background while the current one rests (#240)
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:45:21 -04:00
Brandon ThomasandClaude Opus 5.5 7d87b6e668 Fix up merged PRs: transition sounds and media cues pass the document uid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:01 -04:00
Brandon Thomas 0ed74b684a Merge the SmartArt and dialog PRs integrated on land-r1 (#596 #601 #606 #610 #613 #617 #621 #623 #627 #634 #557 #689 #668)
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:44:43 -04:00
Brandon Thomas 8d429a23e9 Merge PR #483 from NikitkaRa: Media: keep playback with the document and the clip (#415)
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 13:44:40 -04:00
Brandon Thomas b0345cfaf2 Merge PR #602 from NikitkaRa: Charts: polar radar, stock high-low lines and up/down bars, combo sample 2026-10-14 13:42:48 -04:00
Brandon Thomas 82ea49bcf1 Merge PR #598 from NikitkaRa: Charts: keep and draw region map charts 2026-10-14 13:42:45 -04:00
Brandon Thomas 7c46b5b76a Merge PR #594 from NikitkaRa: Charts: multi-level treemaps and sunbursts; inclusive box quartiles 2026-10-14 13:42:41 -04:00
Brandon Thomas 6057715c2a Merge PR #591 from NikitkaRa: Charts: histograms, Pareto and box and whisker; Office 2016 charts save as themselves
# Conflicts:
#	crates/render/src/chart.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 13:42:41 -04:00
Brandon ThomasandClaude Opus 5.5 b5499d7157 Fix up merged PRs: waterfall axis scale uses the running totals
main's axis_scale now reads plotted_extent rather than value_range, which #537
taught about waterfalls; route waterfall charts to value_range there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:41:50 -04:00
Brandon ThomasandClaude Opus 5.5 0376c0b0e1 Fix up merged PRs: one parity summary, one dialogs gap entry, no duplicated architecture rows
Recomputed docs/parity-checklist.md's summary with cargo xtask parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:41:30 -04:00
Brandon Thomas 9479e3b17c Merge PR #537 from NikitkaRa: Charts: draw waterfall, funnel, treemap and sunburst layouts
# Conflicts:
#	crates/model/src/chart.rs
#	crates/render/src/tests.rs
2026-10-14 13:38:34 -04:00
Brandon ThomasandClaude Opus 5.5 2f6629c4cb Fix up merged PRs: theme browsing's importer sets vml_previews (#530 after #209-era field)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:37:42 -04:00
Brandon Thomas 5b017f4590 Merge PR #682 from NikitkaRa: UI: Alt Text's decorative checkbox follows the selection
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:36:55 -04:00
Brandon Thomas f52bcfa1c8 Merge PR #530 from NikitkaRa: Design: Browse for Themes from a .thmx, .pptx or .potx file
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 13:36:45 -04:00
Brandon Thomas d00b01b5a1 Merge PR #540 from NikitkaRa: Table: borders, cell margins and cell text alignment
# Conflicts:
#	crates/engine/src/cmd/table.rs
#	crates/engine/src/tests.rs
2026-10-14 13:36:34 -04:00
Brandon Thomas 9ed995d8d8 Merge PR #699 from NikitkaRa: Media: damaged MP4 sample tables no longer abort the app
# Conflicts:
#	ATTRIBUTION.md
#	crates/media/src/audio.rs
#	crates/media/src/probe.rs
#	crates/media/tests/decode.rs
2026-10-14 13:35:26 -04:00
Brandon Thomas 0f3495e55d Merge PR #512 from NikitkaRa: Home: Replace Fonts dialog and font.replace command
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 13:34:06 -04:00
Brandon Thomas 62fe8571f6 Merge PR #687 from NikitkaRa: UI: Create New Theme Fonts dialog edits the theme in one step
# Conflicts:
#	crates/engine/src/cmd/design.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:33:36 -04:00
Brandon Thomas 9b8871c86b Merge PR #528 from NikitkaRa: Format: bound total embedded media when opening a .deckcraft
# Conflicts:
#	crates/format/src/lib.rs
2026-10-14 13:31:49 -04:00
Brandon Thomas 7f9b508296 Merge PR #685 from NikitkaRa: PPTX: keep the notes master's own placeholders and colour map on save
# Conflicts:
#	crates/pptx/src/write/mod.rs
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:31:17 -04:00
Brandon Thomas 76bfc19dfa Merge PR #592 from NikitkaRa: Notes Page view draws master text wrapped (stacked on #583) 2026-10-14 13:30:54 -04:00
Brandon Thomas 4cd6412a3c Merge PR #583 from NikitkaRa: PDF master header/footer text wraps and keeps mixed formatting (stacked on #580)
# Conflicts:
#	crates/render/src/lib.rs
#	crates/render/src/placed.rs
2026-10-14 13:30:43 -04:00
Brandon Thomas 18be662a2a Merge PR #668 from NikitkaRa: UI: Date and Time dialog inserts a format in one step
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:30:31 -04:00
Brandon Thomas 6f634a9809 Merge PR #580 from NikitkaRa: Notes Master slide preview; master header/footer text keeps its formatting (stacked on #577) 2026-10-14 13:30:28 -04:00
Brandon Thomas 2d8014c706 Merge PR #577 from NikitkaRa: Notes pages and PDF handouts follow their masters (stacked on #573)
# Conflicts:
#	crates/pdf/src/lib.rs
#	crates/pdf/src/tests.rs
2026-10-14 13:30:22 -04:00
Brandon ThomasandClaude Opus 5.5 4653cb4e18 Fix up merged PRs: Hyperlink dialog tests fill main's new sound fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:30:15 -04:00
Brandon Thomas e0ec02d62c Merge PR #689 from NikitkaRa: UI: Hyperlink dialog follows the selection
# Conflicts:
#	crates/engine/src/cmd/insert.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:30:10 -04:00
Brandon Thomas 63c32b89b8 Merge PR #573 from NikitkaRa: Add the Notes Master view (stacked on #572 / #533)
# Conflicts:
#	crates/pptx/src/write/mod.rs
2026-10-14 13:29:52 -04:00
Brandon ThomasandClaude Opus 5.5 4c1357b2e6 Fix up merged PRs: shape.action fills main's new Hyperlink sound fields and CustomShow returns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:29:52 -04:00
Brandon Thomas f685deef57 Merge PR #557 from NikitkaRa: Insert: Action Settings dialog and shape.action
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:29:43 -04:00
Brandon Thomas 875df7674c Merge PR #572 from NikitkaRa: Add the Handout Master view (stacked on #533)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:29:33 -04:00
Brandon ThomasandClaude Opus 5.5 47cf76f32c Fix up merged PRs: handout master csld takes tags; corpus test hyperlink gains sound fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:29:27 -04:00
Brandon Thomas 085ba98d47 Merge PR #533 from NikitkaRa: Pptx: round-trip tables, charts, animation, media, sections and masters
# Conflicts:
#	crates/pptx/src/write/mod.rs
2026-10-14 13:29:06 -04:00
Brandon Thomas 5fd7984088 Merge PR #634 from NikitkaRa: SmartArt: layout gallery thumbnails and hover preview 2026-10-14 13:29:01 -04:00
Brandon Thomas fd1b62bded Merge PR #627 from NikitkaRa: SmartArt: Convert to SmartArt gallery on Home; Convert to Text keeps the text size 2026-10-14 13:29:01 -04:00
Brandon Thomas 510fb4b0b7 Merge PR #623 from NikitkaRa: SmartArt: Name and Title org chart, closer Half Circle, ten more layouts, convert to/from text and shapes 2026-10-14 13:29:01 -04:00
Brandon Thomas aa56910de8 Merge PR #621 from NikitkaRa: SmartArt: organisation charts with assistants, segmented/multidirectional cycles, cycle matrix, segmented pyramid, pie 2026-10-14 13:29:01 -04:00
Brandon Thomas 981b09da83 Merge PR #617 from NikitkaRa: SmartArt: six more layouts, clear Venn labels, read back older Picture Accent Process files 2026-10-14 13:29:01 -04:00
Brandon Thomas 25eb575146 Merge PR #613 from NikitkaRa: SmartArt: node pictures from the data model, Brick Scene camera, four more layouts 2026-10-14 13:26:24 -04:00
Brandon Thomas a19b199efc Merge PR #610 from NikitkaRa: SmartArt: shrink text to fit, picture layouts, 3-D styles and more colours 2026-10-14 13:26:24 -04:00
Brandon Thomas b4ea3b1df8 Merge PR #535 from NikitkaRa: PPTX: keep custom XML, the sensitivity label and custom properties
# Conflicts:
#	crates/model/src/defaults.rs
#	crates/model/src/lib.rs
#	crates/pptx/src/read/mod.rs
#	crates/pptx/src/write/mod.rs
2026-10-14 13:26:22 -04:00
Brandon Thomas 175142f1d3 Merge PR #606 from NikitkaRa: SmartArt: 15 layouts, SmartArt Styles and Change Colors 2026-10-14 13:26:21 -04:00
Brandon Thomas eabcf47d09 Merge PR #601 from NikitkaRa: SmartArt: write the outline to .pptx as a diagram and read it back from the data model
# Conflicts:
#	crates/engine/Cargo.toml
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/shapes.rs
2026-10-14 13:26:21 -04:00
Brandon Thomas d534343d6e Merge PR #683 from NikitkaRa: UI: Create New Theme Colors dialog edits the theme in one step
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:26:11 -04:00
Brandon Thomas a205d665b0 Merge PR #658 from NikitkaRa: UI: Edit Photo Album reflows the album in one step 2026-10-14 13:26:02 -04:00
Brandon Thomas 2834166904 Merge PR #647 from NikitkaRa: UI: Photo Album dialog inserts pictures as slides
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-14 13:26:02 -04:00
Brandon Thomas 55993247b4 Merge PR #191 from isaac-levine: Slides: Ctrl+A in the slide pane selects every slide (#188)
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-14 13:25:57 -04:00
Brandon Thomas 20392c955b Merge PR #531 from NikitkaRa: PPTX: keep add-in tags on the presentation, slides and shapes
# Conflicts:
#	crates/pptx/src/read/mod.rs
#	crates/pptx/src/write/mod.rs
2026-10-14 13:25:50 -04:00
Brandon Thomas 84d60009be Merge PR #596 from NikitkaRa: SmartArt: Text Pane, add and remove shapes, promote and demote
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/panes.rs
2026-10-14 13:25:39 -04:00
Brandon Thomas 8da7862348 Merge PR #651 from NikitkaRa: Text: shrink on overflow gives up at once when even 10% overflows
# Conflicts:
#	crates/text/src/lib.rs
#	crates/text/src/tests.rs
2026-10-14 13:25:33 -04:00
Brandon Thomas 5a3bc1e294 Merge PR #532 from NikitkaRa: PPTX: read Office 2016 charts (waterfall, funnel, treemap…) and keep them 2026-10-14 13:25:05 -04:00
Brandon Thomas 534925bdf1 Merge PR #547 from NikitkaRa: Pictures: Recolor gallery and Set Transparent Color
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-14 13:23:51 -04:00
Brandon Thomas d0fb793954 Merge PR #545 from NikitkaRa: Pictures: Picture Styles gallery and Picture Border
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/tests.rs
2026-10-14 13:23:36 -04:00
Brandon Thomas 6af2b0ee21 Merge PR #618 from NikitkaRa: Text: underline an IME composition in a table cell
# Conflicts:
#	crates/render/src/lib.rs
2026-10-14 13:23:32 -04:00
Brandon Thomas 3bfaeffa6c Merge PR #695 from NikitkaRa: Outline: lay out only the slides in view
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/sorter.rs
2026-10-14 13:23:25 -04:00
Brandon Thomas 058890ed87 Merge PR #518 from NikitkaRa: Pictures: Artistic Effects — apply, change, remove and keep in PPTX
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/tests.rs
#	crates/pptx/src/write/dml.rs
2026-10-14 13:23:07 -04:00
Brandon Thomas d4d352622a Merge PR #665 from NikitkaRa: Format: repair degenerate slide sizes on load
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 13:22:57 -04:00
Brandon Thomas 1ac61262eb Merge PR #495 from NikitkaRa: Animation Pane: advanced timeline with draggable bars
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 13:22:52 -04:00
Brandon Thomas 72238c1b6e Merge PR #678 from NikitkaRa: Merge Shapes: text merges as its letters, so a picture can fill them
# Conflicts:
#	crates/render/src/lib.rs
2026-10-14 13:22:48 -04:00
Brandon Thomas 5c0630cba3 Merge PR #586 from NikitkaRa: UI: sections in the Slide Sorter
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/sorter.rs
2026-10-14 13:21:53 -04:00
Brandon Thomas 0f7dc3d336 Merge PR #593 from NikitkaRa: File: six original starter decks
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-14 13:21:23 -04:00
Brandon Thomas 32a2d9e6bd Merge PR #560 from NikitkaRa: UI: View › New Window opens a second window
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-14 13:21:19 -04:00
Brandon Thomas ab3b1defbf Merge PR #140 from alexanderalber: Slide show: media set to play in the click sequence starts on Next (#116)
# Conflicts:
#	crates/anim/src/tests.rs
#	crates/ui-egui/src/show.rs
2026-10-14 13:20:15 -04:00
Brandon Thomas e80316eb76 Merge PR #85 from MarekSpodBiedry: Comments: browser-created comments and replies get a creation timestamp (#72)
# Conflicts:
#	crates/engine/Cargo.toml
#	crates/engine/src/cmd/review.rs
2026-10-14 13:19:07 -04:00
Brandon ThomasandClaude Opus 5.5 edaef792e0 Fix up merged PRs: Edit Points hits select their shape on right click; Hyperlink sound fields in #713's tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:11:36 -04:00
Brandon Thomas 8a56ce8340 Merge PR #705 from NikitkaRa: PPTX: write Color Pulse emphasis as a colour change, not a scale
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:09:10 -04:00
Brandon Thomas 6508be38f6 Merge PR #704 from NikitkaRa: Undo: cap the history by memory as well as by steps
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:09:10 -04:00
Brandon Thomas 62f7075b42 Merge PR #701 from NikitkaRa: PPTX: normalize literal line ends in XML so runs stay put on save
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:09:09 -04:00
Brandon Thomas 30a1234c33 Merge PR #696 from NikitkaRa: Media: damaged audio no longer hangs or panics 2026-10-14 13:09:09 -04:00
Brandon Thomas 9ae4d726d4 Merge PR #694 from NikitkaRa: PPTX: keep table styles the file defines when no table uses them 2026-10-14 13:09:08 -04:00
Brandon Thomas a567c328cd Merge PR #681 from NikitkaRa: Export: image exports keep the whole slide; partial failures say what was written
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:09:07 -04:00
Brandon Thomas a5ce62fe28 Merge PR #672 from NikitkaRa: Review: previous and next comment 2026-10-14 13:09:06 -04:00
Brandon Thomas 337bd35a31 Merge PR #663 from NikitkaRa: Render: don't lay out table cell text that lands off the slide
# Conflicts:
#	crates/render/src/tests.rs
2026-10-14 13:09:05 -04:00
Brandon Thomas 6a8922ac49 Merge PR #661 from NikitkaRa: Startup: fall back to Wayland when DISPLAY names no X server 2026-10-14 13:09:05 -04:00
Brandon Thomas f0d0e2a93b Merge PR #659 from NikitkaRa: Charts: percent axes for 100% stacked; source-linked axis number formats 2026-10-14 13:09:05 -04:00
Brandon Thomas facca31f75 Merge PR #657 from NikitkaRa: Charts: draw axis titles 2026-10-14 13:09:05 -04:00
Brandon Thomas 2c57da32ad Merge PR #643 from NikitkaRa: Shapes: chevron text rectangle per presetShapeDefinitions 2026-10-14 13:09:03 -04:00
Brandon Thomas bd8e227596 Merge PR #642 from NikitkaRa: Shapes: star4/star5 text rectangles as the standard defines them 2026-10-14 13:09:03 -04:00
Brandon Thomas 9eed1b4532 Merge PR #641 from NikitkaRa: UI: Size and Position dialog sets geometry in one step 2026-10-14 13:09:03 -04:00
Brandon Thomas e7a106fcc3 Merge PR #639 from NikitkaRa: Render: cap dash count so a tiny dash on a huge shape can't crash on open 2026-10-14 13:09:03 -04:00
Brandon Thomas a87f84526f Merge PR #625 from NikitkaRa: Insert: a gallery of Lucide icons as stroked shapes 2026-10-14 13:09:01 -04:00
Brandon Thomas 8080e5a285 Merge PR #620 from NikitkaRa: Animations: check effect options and accept direction words 2026-10-14 13:09:01 -04:00
Brandon Thomas 2dcfbdaff4 Merge PR #600 from NikitkaRa: render: 3-D contours 2026-10-14 13:08:59 -04:00
Brandon Thomas d48d66b7f8 Merge PR #597 from NikitkaRa: render: 3-D light rigs and materials, per-side extrusion shading 2026-10-14 13:08:58 -04:00
Brandon Thomas 5d4e08733b Merge PR #595 from NikitkaRa: Render 3-D extrusions: sides under turned cameras (3-D slice 1)
# Conflicts:
#	crates/render/src/tests.rs
2026-10-14 13:08:58 -04:00
Brandon Thomas 0bab72aff2 Merge PR #590 from NikitkaRa: PPTX: round-trip chart trendlines 2026-10-14 13:08:57 -04:00
Brandon Thomas 1080d02dd0 Merge PR #589 from NikitkaRa: PPTX corpus: themes, masters, effects and links; keep text Mouse Over actions 2026-10-14 13:08:57 -04:00
Brandon Thomas 15b9ae0267 Merge PR #578 from NikitkaRa: PPTX: round-trip 3-D models and animation bounce end 2026-10-14 13:08:56 -04:00
Brandon Thomas 437153c8b5 Merge PR #574 from NikitkaRa: Metafile: draw EMF+ only pictures
# Conflicts:
#	crates/metafile/src/tests.rs
2026-10-14 13:08:55 -04:00
Brandon Thomas 99e9774408 Merge PR #566 from NikitkaRa: Make PDF export ~2.8× faster with byte-identical output 2026-10-14 13:08:54 -04:00
Brandon Thomas 3a5153a1d0 Merge PR #564 from NikitkaRa: Performance baseline: deckcraft-cli bench and cargo xtask bench 2026-10-14 13:08:54 -04:00
Brandon Thomas f4c182795f Merge PR #562 from NikitkaRa: Pptx: round-trip text effects, layout, fills, bevels and connectors 2026-10-14 13:08:54 -04:00
Brandon Thomas 2bd01910dc Merge PR #558 from NikitkaRa: Edit: Paste Special (destination theme, keep source, picture, text only)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:54 -04:00
Brandon Thomas 4dea355f02 Merge PR #554 from NikitkaRa: Pptx: round-trip comments, links, shows, groups and inherited placeholders 2026-10-14 13:08:53 -04:00
Brandon Thomas 6e294a0eaa Merge PR #553 from NikitkaRa: Text: Bullets and Numbering dialog
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:53 -04:00
Brandon Thomas e171e47a81 Merge PR #551 from NikitkaRa: Animations: add 34 PowerPoint motion path presets
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:52 -04:00
Brandon Thomas a67b005ae5 Merge PR #550 from NikitkaRa: Pictures: Remove Background with a classical matte 2026-10-14 13:08:52 -04:00
Brandon Thomas 5b26eeb7f7 Merge PR #541 from NikitkaRa: Morph: the same text moves and resizes instead of cross-fading
# Conflicts:
#	crates/anim/src/tests.rs
2026-10-14 13:08:51 -04:00
Brandon Thomas c201a62e72 Merge PR #526 from NikitkaRa: Format: report damaged embedded media and open such files as a repaired copy
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:49 -04:00
Brandon Thomas c9e071c355 Merge PR #517 from NikitkaRa: PPTX: owned corpus round-trip, keep notes-master header and footer 2026-10-14 13:08:48 -04:00
Brandon Thomas 11e36ed3ac Merge PR #516 from NikitkaRa: Transitions: built-in sounds, Stop Previous Sound and Loop Until Next 2026-10-14 13:08:48 -04:00
Brandon Thomas aaf1042d64 Merge PR #515 from NikitkaRa: Shapes: Edit Points — point types, Bézier handles, right-click menu 2026-10-14 13:08:48 -04:00
Brandon Thomas 02b502b69d Merge PR #514 from NikitkaRa: Shapes: Edit Points — drag, add and delete a shape's vertices
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:48 -04:00
Brandon Thomas e12f40a05a Merge PR #511 from NikitkaRa: UI: WordArt Transform gallery applies a text warp 2026-10-14 13:08:48 -04:00
Brandon Thomas 5334442eb5 Merge PR #507 from NikitkaRa: UI: seeded whole-window soak with memory and timing bounds 2026-10-14 13:08:47 -04:00
Brandon Thomas cc0a2f3dc5 Merge PR #505 from NikitkaRa: Transitions: choose a transition sound and play it in the show
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:08:47 -04:00
Brandon Thomas f4892fc9e7 Merge PR #504 from NikitkaRa: Text: set text transparency from Text Fill (#32)
# Conflicts:
#	crates/engine/src/tests.rs
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:08:47 -04:00
Brandon Thomas 16138fc03d Merge PR #503 from NikitkaRa: Format Shape: shadow, glow and reflection detail controls
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:47 -04:00
Brandon Thomas 6f2b41f236 Merge PR #502 from NikitkaRa: Engine: seeded editor soak tests; tiny charts no longer blank the slide
# Conflicts:
#	crates/render/src/tests.rs
2026-10-14 13:08:46 -04:00
Brandon Thomas ca7140c1ff Merge PR #500 from NikitkaRa: Format Shape: line compound, cap, join, arrows and sizes; lock aspect ratio
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:46 -04:00
Brandon Thomas 06a6b10d83 Merge PR #499 from NikitkaRa: Print: choose paper size and orientation for notes and handouts
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:46 -04:00
Brandon Thomas 20cf13a801 Merge PR #491 from NikitkaRa: Fonts: find fonts where Linux and Flatpak keep them; add and rescan commands (#277)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:45 -04:00
Brandon Thomas e1af96d3a6 Merge PR #489 from NikitkaRa: Designer: layout ideas that rearrange the current slide (#314) 2026-10-14 13:08:45 -04:00
Brandon Thomas bce94027ad Merge PR #486 from NikitkaRa: Charts: pie and doughnut data labels use the number format (#324) 2026-10-14 13:08:45 -04:00
Brandon Thomas 0950423f08 Merge PR #482 from NikitkaRa: Recovery: unique uids, per-instance ownership, keep snapshots until replaced (#390) 2026-10-14 13:08:44 -04:00
Brandon Thomas 163a259db4 Merge PR #480 from NikitkaRa: Pptx: budget chart points, chart XML size, inflate work and VML lookups (#401) 2026-10-14 13:08:44 -04:00
Brandon Thomas 215fed001e Merge PR #478 from NikitkaRa: Render: cap RGBA conversion and bound the failed-image cache (#402) 2026-10-14 13:08:44 -04:00
Brandon Thomas 8195e3b6d8 Merge PR #477 from NikitkaRa: Text: bidi levels for language runs in linear time (#403) 2026-10-14 13:08:44 -04:00
Brandon Thomas 6484091328 Merge PR #476 from NikitkaRa: Render: budget shape path-gradient work on dense outlines (#404) 2026-10-14 13:08:44 -04:00
Brandon Thomas 50e30dc6ed Merge PR #475 from NikitkaRa: Edit: copies remap media, connector ends, animation targets and triggers (#406)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 13:08:43 -04:00
Brandon Thomas 21c5f8e745 Merge PR #472 from NikitkaRa: Anim: group animations by sequence in one pass (#410)
# Conflicts:
#	crates/anim/src/tests.rs
2026-10-14 13:08:43 -04:00
Brandon Thomas 994d9a4b66 Merge PR #467 from NikitkaRa: Parsers: check hostile numeric fields before arithmetic (#426) 2026-10-14 13:08:43 -04:00
Brandon Thomas 78b6c2982f Merge PR #439 from NikitkaRa: Pptx: read and write embedded fonts
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 13:08:43 -04:00
Brandon Thomas 271822164f Merge PR #437 from NikitkaRa: Metafile: clip EMF and WMF drawing to rectangle regions
# Conflicts:
#	crates/render/src/tests.rs
2026-10-14 13:08:42 -04:00
Brandon Thomas c15b1deaf9 Merge PR #375 from NikitkaRa: MCP tools refuse an argument that is not in their schema (#371)
# Conflicts:
#	crates/mcp/src/tests.rs
2026-10-14 13:08:42 -04:00
Gert Pellin 50d9ad00e6 Slide: a new slide's body goes into the layout's content placeholder
slide.new put the body into the first non-title placeholder in shape order,
so a layout with a small body placeholder ahead of its content placeholder
(as Keynote exports "Title & Bullets") got the body in the small box and
left the content placeholder empty.

The body now goes to the main content placeholder: idx 1 first, then a
full-size placeholder over a half/quarter one, then a text placeholder over
a picture/chart/table one, keeping shape order on ties.

Fixes #725
2026-10-14 19:06:35 +02:00
Brandon ThomasandClaude Opus 5.5 a837c26eda Engine: Selection pane select uses the checked shape id param (#97 after main's selection.select)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:00:05 -04:00
Brandon Thomas 849f543d38 Merge PR #376 from Momentum96: ui: admit stock macOS CJK faces as interface fallbacks (#101)
# Conflicts:
#	contributors/people.toml
2026-10-14 12:58:02 -04:00
Brandon Thomas 5b3f4cb521 Merge PR #253 from xmaks82: Text: line spacing reduction comes off the percentage, not the spacing
# Conflicts:
#	crates/text/src/tests.rs
2026-10-14 12:58:01 -04:00
Brandon Thomas 97e7164026 Merge PR #249 from xmaks82: Text: exact line spacing ends at the last line's descent
# Conflicts:
#	crates/text/src/tests.rs
2026-10-14 12:58:01 -04:00
Brandon Thomas 44f4b037e2 Merge PR #244 from pierrevbc: Canvas: right click selects the shape under the pointer
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 12:58:01 -04:00
Brandon Thomas 402c1f4611 Merge PR #222 from justinjohn0306: Animations: nine PowerPoint exit effects import as themselves (#147)
# Conflicts:
#	crates/anim/src/tests.rs
#	crates/pptx/tests/roundtrip.rs
2026-10-14 12:58:00 -04:00
Brandon Thomas 4dcce01ecc Merge PR #218 from LloydNicholson: Read Markdown outlines as Markdown (#211)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 12:58:00 -04:00
Brandon Thomas a5eae5e9cf Merge PR #216 from justinjohn0306: PPTX: a lone guide keeps the name its preset uses, adj1 or adj (#214)
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 12:58:00 -04:00
Brandon Thomas 4dc6e06c41 Merge PR #212 from justinjohn0306: PPTX: .potx and .ppsx declare a template and slide show main part (#210)
# Conflicts:
#	crates/pptx/tests/roundtrip.rs
2026-10-14 12:58:00 -04:00
Brandon Thomas 8ffb218f78 Merge PR #356 from natocool2: Bump quick-xml to 0.41 (RUSTSEC-2026-0194, RUSTSEC-2026-0195) 2026-10-14 12:57:24 -04:00
Brandon Thomas 973abff543 Merge PR #351 from dotCooCoo: Fonts: the font scan leaves out faces with no outline tables 2026-10-14 12:57:23 -04:00
Brandon Thomas 30d282d510 Merge PR #348 from marcoarnulfo: Open: a password-protected presentation says so, and open errors show on the start screen 2026-10-14 12:57:23 -04:00
Brandon Thomas f8d52f6fbe Merge PR #308 from marcoarnulfo: macOS: end the process in on_exit so closing the window doesn't crash 2026-10-14 12:57:23 -04:00
Brandon Thomas fc74794394 Merge PR #306 from FabPei: fix: DeckCraft upstream issue #104 2026-10-14 12:57:23 -04:00
Brandon Thomas af1c6696ca Merge PR #280 from albayazid: Comments: threads and resolved state survive a .pptx round trip (#70) 2026-10-14 12:57:23 -04:00
Brandon Thomas b6721fe32f Merge PR #246 from pierrevbc: Title bar: Export button next to Save 2026-10-14 12:57:22 -04:00
Brandon Thomas b7cb5331d7 Merge PR #242 from pierrevbc: SmartArt: text is laid out on its text rectangle (dsp:txXfrm), not in columns 2026-10-14 12:57:22 -04:00
Brandon Thomas 1256ee1022 Merge PR #226 from xmaks82: Text: draw shrink-on-overflow at the scale the file was saved with 2026-10-14 12:57:21 -04:00
Brandon Thomas 888b5d7287 Merge PR #205 from justinjohn0306: Home: a Selection Pane button that works with nothing selected (#132) 2026-10-14 12:57:20 -04:00
Brandon Thomas 2ae8fe1839 Merge PR #201 from justinjohn0306: Slide Size: choose Portrait or Landscape (#198) 2026-10-14 12:57:20 -04:00
Brandon Thomas e13db7f564 Merge PR #189 from Claudio911015: Ctrl+C then Ctrl+V pastes objects that carry no text (Fixes #182) 2026-10-14 12:57:20 -04:00
Brandon Thomas 8d8cbeb123 Merge PR #179 from MarekSpodBiedry: CLI: --help prints usage and unknown render flags are rejected (#163) 2026-10-14 12:57:20 -04:00
Brandon Thomas 40231d8114 Merge PR #176 from itrycodinganditsbad: Windows: compile DX12 shaders with FXC 2026-10-14 12:57:20 -04:00
Brandon Thomas 6ec369aede Merge PR #160 from MarekSpodBiedry: Ribbon: transition and animation tiles follow the dark theme (#159) 2026-10-14 12:57:19 -04:00
Brandon Thomas 9fab337cd2 Merge PR #157 from LloydNicholson: Charts: value axes start above zero when the values sit high, as in Office 2026-10-14 12:57:19 -04:00
Brandon Thomas 5992cbeb15 Merge PR #156 from LloydNicholson: Issue #106: zooming keeps the point under the pointer 2026-10-14 12:57:19 -04:00
Brandon Thomas d281ba7707 Merge PR #149 from alexanderalber: PPTX import: a transition without spd is 0.5 s, not the effect's default (#117) 2026-10-14 12:57:19 -04:00
Brandon Thomas a535040d6d Merge PR #98 from dylanmtaylor: docs(readme): percent-encode middle dot in platforms badge URL 2026-10-14 12:57:19 -04:00
Brandon Thomas c57d25e8d3 Merge PR #97 from mikl0s: Commands: an invalid shape id is an error instead of the selection 2026-10-14 12:57:19 -04:00
Brandon Thomas 6d57610489 Merge PR #96 from mikl0s: Slides: going to a slide that does not exist is an error 2026-10-14 12:57:19 -04:00
Brandon Thomas 7862974cad Merge PR #95 from mikl0s: Export: an unknown format is an error instead of a deckcraft file 2026-10-14 12:57:19 -04:00
Brandon Thomas 24644f3e3b Merge PR #83 from MarekSpodBiedry: PPTX: animation rewind survives save and reopen (#77) 2026-10-14 12:57:18 -04:00
Brandon Thomas 8409c337c1 Merge PR #82 from MarekSpodBiedry: Command palette: one Export… entry that opens the dialog, no path-only duplicate (#79) 2026-10-14 12:57:18 -04:00
Brandon Thomas 2578a92e96 Merge PR #80 from LloydNicholson: Fonts: a lookup finds an installed family whichever thread loads it first 2026-10-14 12:57:18 -04:00
Brandon Thomas 194e7fcf1b Merge PR #450 from MarekSpodBiedry: UI: notes, alt text and rename drafts no longer carry over to another document (#413)
# Conflicts:
#	crates/ui-egui/src/widgets.rs
2026-10-14 12:54:18 -04:00
Brandon Thomas 1171e92308 Merge PR #707 from lambertoo: Fix keyboard navigation when Presenter View has focus
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-14 12:54:05 -04:00
Brandon Thomas b9b0fdbab0 Merge PR #587 from MMexicann: Media: preserve video frames and EOF across seeks 2026-10-14 12:53:42 -04:00
Brandon Thomas 903cf0930b Merge PR #463 from Zetenypapp: Copy and Cut buttons and menus put the text on the system clipboard (#431) 2026-10-14 12:53:42 -04:00
Brandon Thomas 5d406be3b0 Merge PR #460 from albayazid: Slide show: Honeycomb's hexagons cover the whole slide again (#420) 2026-10-14 12:53:42 -04:00
Brandon Thomas 3635bdc8ba Merge PR #713 from nagasakimark: Slide show: click-triggered animations, per-slide state and hit testing 2026-10-14 12:53:34 -04:00
Brandon Thomas a9b8cc4f4a Merge PR #728 from midasmatterhorn: pptx: write linked pictures with r:link so PowerPoint resolves external images 2026-10-14 12:53:34 -04:00
Brandon Thomas 9ab6a3a35c Merge PR #716 from switch87: CLI: convert and run refuse output formats DeckCraft can't write (#715) 2026-10-14 12:53:34 -04:00
Brandon Thomas e3a654d446 Merge PR #459 from MarekSpodBiedry: Slide: leading tabs in a new slide's body set the outline level (#370) 2026-10-14 12:53:33 -04:00
Brandon Thomas f4ae239725 Merge PR #458 from MarekSpodBiedry: AV1: reject tile groups that repeat, skip or reorder tiles (#395) 2026-10-14 12:53:33 -04:00
Brandon Thomas 1d8f460fc5 Merge PR #457 from MarekSpodBiedry: Matroska: stop following chapter and tag nesting past 32 levels (#396) 2026-10-14 12:53:33 -04:00
Brandon Thomas 838cef2fec Merge PR #456 from MarekSpodBiedry: Bitstream: reject bad NAL length widths, check NAL endpoints and signed Exp-Golomb limits (#397) 2026-10-14 12:53:33 -04:00
Brandon Thomas d184eb7927 Merge PR #455 from MarekSpodBiedry: Table: split and cell fill stay inside the table (#400) 2026-10-14 12:53:33 -04:00
Brandon Thomas 3046d49e56 Merge PR #454 from MarekSpodBiedry: Engine: a command that fails after editing puts the presentation back (#405) 2026-10-14 12:53:33 -04:00
Brandon Thomas 2b20fc3a45 Merge PR #453 from MarekSpodBiedry: MCP: do not resend a command whose reply was lost (#409) 2026-10-14 12:53:33 -04:00
Brandon Thomas ac88cad50b Merge PR #451 from MarekSpodBiedry: Pptx: keep the math namespace equations inherit from the slide root or a wrapper (#412) 2026-10-14 12:53:33 -04:00
Brandon Thomas f1b30485b2 Merge PR #448 from MarekSpodBiedry: Format scientific number formats (0.00E+00) in chart labels (#419) 2026-10-14 12:53:32 -04:00
Brandon Thomas 9b94481481 Merge PR #446 from MarekSpodBiedry: Engine: moving the selection to another text body settles the composition (#421) 2026-10-14 12:53:32 -04:00
Brandon Thomas ab2befffa0 Merge PR #445 from MarekSpodBiedry: Info: keep a property draft when another field or the side menu is clicked (#422) 2026-10-14 12:53:32 -04:00
Brandon Thomas ea480ad141 Merge PR #443 from MarekSpodBiedry: xtask: assets audit no longer panics on Unicode names and reads real paths from git (#424) 2026-10-14 12:53:32 -04:00
Brandon Thomas a7ed1767b5 Merge PR #442 from MarekSpodBiedry: Opus: validate the header in Decoder::from_head (#425) 2026-10-14 12:53:32 -04:00
Brandon Thomas 44df1ebcb0 Merge PR #436 from Silvertree2010: Colour menus: an Eyedropper that takes a colour from the slide (#367) 2026-10-14 12:53:32 -04:00
midasmatterhorn 5d31b78736 pptx: write linked pictures with r:link so PowerPoint resolves external images 2026-10-14 18:01:42 +03:00
Brandon ThomasandClaude Opus 5.5 d78e657e4c Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:33:03 -04:00
Gert Pellin cf640c4b08 CLI: convert and run refuse output formats DeckCraft can't write (#715)
convert, run --save and run --export picked the format from the output
extension and fell back to native .deckcraft bytes for any extension
they didn't know, so out.odp, out.gif, out.svg and out.outline were
zips with exit status 0. They now fail before writing anything, with
the extension and the supported ones in the error.
2026-10-14 13:07:49 +02:00
Claude 887ea1e706 Slide show: click-triggered animations, per-slide state and hit testing
Make the slide show behave like PowerPoint for interactive decks.

- Hit testing resolves the topmost visible shape under the pointer.
  Non-interactive shapes block shapes beneath them; the hand cursor
  shows only over interactive shapes.
- Click-triggered animations (interactive sequences) play on shape
  click, repeat after their last step, and apply in click order so
  cycling "score ring" shapes work.
- Each slide keeps its own progress: returning to a slide through a
  link or Back restores hidden shapes and animation state.
- A shape with a link and an animation follows the link immediately;
  its animation clock only runs while the slide is shown and resumes
  with the remaining time on return.
- With advance-on-click off, background clicks still play animation
  steps but never change slide.
- A click on a playing step completes it at once.

Docs (gaps, animation and slide show parity, roadmap) are updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 17:22:23 +09:00
skylar taylor-barrick 42fb6728fa PDFW2: use fixed-size chunks in PDF text regression 2026-10-14 00:11:33 -06:00
rulindanalambert ba290f171e Fix presenter-view keyboard navigation and end-screen controls 2026-10-14 07:04:45 +02:00
Nikita Rakitin f036a8e805 PPTX: Color Pulse emphasis is written as a colour change, not a grow 2026-10-14 06:44:59 +03:00
Nikita RakitinandCursor 084e076c1d Undo: cap the history by memory as well as by steps
Undo kept up to 500 steps whatever their size. Steps share what an edit didn't touch, so most
are small, but a deck-wide edit (slide size, theme, find-and-replace style changes) keeps a copy
of every slide: 500 of those on a big deck could hold hundreds of copies. Each step's cost is now
estimated when it is pushed (the slides, masters and media it doesn't share with the document
after it), and the oldest steps are dropped while the total passes History::byte_limit
(UNDO_BYTE_BUDGET, 256 MiB, by default). The newest step always stays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:41:16 +03:00
Nikita RakitinandCursor fa4b80ed65 UI: Slide Master Preserve and Title or Footers placeholders
Preserve the open master or layout, and turn its title and footer
placeholders on or off. Each change is one undo step, and a value that
is already set is not.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:40:11 +03:00
Nikita RakitinandCursor 5d9f47c81a Clipboard: rich text and pictures for other apps
Copy puts HTML on the pasteboard with the same plain words, so Pages keeps
bold, italic and underline, and a paste of that HTML keeps them here. A
shape with no text copies out as a picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:37:49 +03:00
Nikita Rakitin ce3725a5c8 PPTX: literal CR LF and CR in XML read as LF; line feeds in run text read as line breaks 2026-10-14 06:37:15 +03:00
Nikita Rakitin 8bbea22f74 Media: refuse MP4s whose sample tables overrun the file before symphonia reserves memory for them 2026-10-14 06:26:15 +03:00
Nikita RakitinandCursor 26d8bfdd3a UI: Design Effects applies a theme effect scheme
design.effects replaces the theme's format scheme (fills, lines and
effects) with DeckCraft, Soft, Crisp, Lift, Glow or Plain. Colours and
fonts stay. The scheme already in use is not an undo step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:21:07 +03:00
Nikita Rakitin 5115f77f08 Media: damaged audio no longer hangs the demux loop or panics in symphonia 2026-10-14 06:20:10 +03:00
Nikita RakitinandCursor 2f3a525568 UI: Format Shape fill transparency follows the selection
The Fill slider seeded itself at 0% and wrote that when it lost focus, so a
solid fill that was already transparent opened as opaque and could be cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:16:14 +03:00
Nikita Rakitin 800c2008df PPTX: table styles the file defines are kept on save even when no table uses them 2026-10-14 06:15:05 +03:00
Nikita RakitinandCursor d6b9cc9b5e Outline: lay out only the slides in view
Outline view built every slide's title and text editors each frame, so a 2000-slide deck took
about 40 ms a frame in a release build (more with real text). Slides outside the view (with a
margin) now take their last measured height, estimated from their line count until first
shown; the current slide is always laid out so its editor keeps the focus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:13:10 +03:00
Nikita RakitinandCursor 1e6292306a UI: Format Background picture fill sets transparency and tiling
A picture background can set transparency, tile, offset, scale,
alignment and mirror. A solid fill can set transparency. Apply to All
copies the current slide's background, including a picture, onto every
slide. Unchanged options are not an undo step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 06:08:40 +03:00
Nikita Rakitin c4a9f25754 Charts: a chart frame with no width no longer panics 2026-10-14 05:58:30 +03:00
Nikita RakitinandCursor 256612b232 UI: Trim Media dialog follows the selection
Trim Media opened at zero for start, end and both fades, so OK
cleared a clip that was already trimmed. The fields now start from
the selected audio or video.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 05:54:32 +03:00
Nikita RakitinandCursor 0fcaabc370 UI: Create New Theme Fonts dialog edits the theme in one step
Design › Fonts › Customize Fonts… opens Create New Theme Fonts.
Empty design.fonts params open the dialog; a name and heading or body
faces apply to the open master in one undo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 05:45:53 +03:00
Nikita Rakitin 6b0c1db6ed PPTX: the notes master's own placeholders and colour map are kept on save 2026-10-14 05:41:01 +03:00
Nikita Rakitin 8c0147cfa7 UI: Hyperlink dialog follows the selection
Insert Hyperlink opened on https:// with an empty ScreenTip, so OK
replaced a web address, email, slide link or tip. The dialog now
starts from the link it would edit, and leaves actions it cannot
edit in place.
2026-10-14 05:40:06 +03:00
Nikita RakitinandCursor d04df05249 Export: record slide resolution in PNG and JPEG pictures
A default export is 2 pixels per point. Without a resolution, Preview
treats those pixels as 72 dpi and the picture opens at twice the slide's
size. PNG pHYs and JPEG JFIF density now say scale times 72, so the
default is 144 dpi. Screenshots stay untagged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 05:35:37 +03:00
Nikita RakitinandCursor 65e465a611 UI: Create New Theme Colors dialog edits the theme in one step
Design › Colors › Customize Colors… opens the dialog when design.colors
is called with no name and no colours. Save applies that scheme name and
the twelve theme colours together, as one undo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 05:24:47 +03:00
Nikita Rakitin f22ff9eb62 Format after merging main 2026-10-14 05:20:38 +03:00
Nikita Rakitin 5398c2397c Merge remote-tracking branch 'origin/main' into m-pptx-chart-workbook
# Conflicts:
#	ROADMAP.md
#	crates/pptx/src/read/chart.rs
#	docs/gaps.md
2026-10-14 05:20:31 +03:00
Nikita RakitinandCursor 3f82866aaf Export: image exports keep the whole slide; partial failures say what was written
The renderer caps a picture at MAX_SIDE (16,000 px) by clipping, so an image export whose
slide × scale passed that (4× of a 4032 pt slide) came out cropped. file.export and file.render
now lower the scale to fit (cmd::file::image_scale). When exporting every slide and one file
can't be written, the error says which slides already were, and slide numbers are checked
before anything is written.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 05:20:06 +03:00
Nikita Rakitin 29c24aa07c UI: Alt Text's decorative checkbox follows the selection
The dialog always opened with Mark as decorative unchecked, so OK
cleared a shape that was already decorative. It now starts checked
when every selected shape is.
2026-10-14 05:19:44 +03:00
Nikita Rakitin 0c84b0434d Merge remote-tracking branch 'origin/main' into m-359-cli-slide-index
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
2026-10-14 05:16:53 +03:00
Nikita Rakitin ef858b710f Merge Shapes: a text box merges as its letters; a picture keeps its place (#653) 2026-10-14 05:07:18 +03:00
Nikita Rakitin c2e770ab53 Show: Show Media Controls hides the playback bar
The Slide Show checkbox wrote nothing. show.setup now stores
showMediaControls, and the show draws the playback bar and GIF button
only while that setting is on.
2026-10-14 05:07:12 +03:00
Nikita RakitinandCursor a9e7747ea5 Canvas: a two-finger twist rotates the selection
macOS delivers a trackpad rotation as egui's Rotate event. While the
pointer is over the canvas, that twist turns the selected shapes, and
the whole gesture is one undo step. Pinch-zoom and scrolling are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:55:33 +03:00
Nikita Rakitin 9f5de924ad View: Zoom dialog applies a percent or Fit
The dialog waits for OK. View ▸ Zoom opens it, and the arrow keeps the preset list.
2026-10-14 04:53:21 +03:00
Nikita Rakitin 3ea696dc1a Charts: read, write and draw error bars 2026-10-14 04:48:33 +03:00
Nikita Rakitin 679ce03da4 Show: a first animation set to With/After Previous plays when the slide appears (#631) 2026-10-14 04:47:09 +03:00
Nikita RakitinandCursor 8fde746211 Format: never save a .deckcraft that can't be reopened over a NaN
serde_json writes NaN and infinity as null, which doesn't read back as a number, so one NaN
anywhere in the model (a rotation, position, crop, stroke width…) made the saved file (and its
AutoRecover copy) fail to open as "damaged".

- Writer: presentation.json goes through finite::Finite, a serializer adapter that writes
  non-finite floats as 0.
- Loader: files already written that way open; each null where a number belongs reads as 0
  (inside tagged enums, where serde only reports the enclosing object, candidates are tried
  nearest first and kept only when the parse gets further). Other damage still fails.
- repair() brings shape geometry into range: positions and sizes finite and within ±1e6 pt,
  rotation 0–360, picture crop -10..1, font sizes 1–4000 pt, line widths 0–1584 pt.

Animation timings are u32 and can't carry NaN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:45:32 +03:00
Nikita Rakitin 4a7ad03eb0 Charts: logarithmic value axes 2026-10-14 04:45:26 +03:00
Nikita RakitinandCursor b6aa6ab4e4 UI: Date and Time dialog inserts a format in one step
Insert › Date & Time opens the format list instead of placing a date
immediately. With no format, update flag or language, insert.dateTime
opens the dialog and does not edit. OK applies one undo: a field when
Update automatically is on, or the shown text when it is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:41:01 +03:00
Nikita Rakitin aed386e269 Charts: draw, read and write value-axis minor gridlines 2026-10-14 04:39:00 +03:00
Nikita Rakitin f0647e1bd8 Review: previous and next comment
Walk every comment in the deck from the Review tab, and list every slide in the Comments pane.
2026-10-14 04:37:49 +03:00
Nikita RakitinandCursor 22074c4c69 Format: repair degenerate slide sizes on load
repair() brings each slide side into PowerPoint's 72–4032 pt (as Design › Slide Size allows;
it accepted 1–4000 before), and replaces a zero, negative or non-finite size with 16:9; a bad
notes size gets the default. The native reader takes a null, string or missing slide/notes
size as NaN for repair to fix, so such a file opens instead of failing as damaged (NaN and
infinity are saved as null). The canvas fit and slide show fit treat a zero or non-finite side
as 1 pt, so an in-memory bad size can't make layout infinite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:35:13 +03:00
Nikita Rakitin 2702b48530 Show: show the slide number while typing it 2026-10-14 04:26:02 +03:00
Nikita Rakitin 8a5eec369f Render: skip laying out table cell text that lands wholly off the target 2026-10-14 04:22:37 +03:00
Nikita Rakitin ed20cebed8 Charts: scatter and bubble charts get a value x axis 2026-10-14 04:22:19 +03:00
Nikita RakitinandCursor fcb5eae1a5 Startup: fall back to Wayland when DISPLAY names no X server
DeckCraft forces X11 whenever DISPLAY is set. A stale DISPLAY (an ended ssh -X session, or
Xwayland turned off) made start-up fail with "failed to open display" while a Wayland
compositor was running. A local DISPLAY whose /tmp/.X11-unix socket is missing now starts on
Wayland when WAYLAND_DISPLAY is set; remote displays and live servers keep X11. Forcing Wayland
without a compositor keeps X11, and with no display at all the error says so plainly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:21:30 +03:00
Nikita Rakitin dfee72e742 Show: play a custom show 2026-10-14 04:19:36 +03:00
Nikita Rakitin 3960ea47cd Charts: 100 % stacked axes count in percent; linked axis formats come from the data 2026-10-14 04:19:16 +03:00
Nikita RakitinandCursor dbffbab59d UI: Edit Photo Album reflows the album in one step
Reopen the Photo Album dialog on the current album and replace those slides
in one undo, with black and white, rotation, brightness, contrast and a theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:16:00 +03:00
Nikita Rakitin fd1b0a1965 Charts: draw the axes' titles 2026-10-14 04:12:18 +03:00
Nikita Rakitin f5fd324892 Show: presenter timer pause, reset and notes size 2026-10-14 04:06:50 +03:00
Nikita RakitinandCursor d31ed52bbe Show: presenter view keeps a portrait Next slide inside its column
The Next preview took the column's full width and the slide's aspect for its height, so a
portrait slide (up to 1:56) drew a white slab many times the window's height over the notes,
which were pushed below the window. It is now fitted into the top 60% of the column; 16:9
slides keep the full width as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 04:04:09 +03:00
Nikita Rakitin 0b66323765 PPTX: shape text without an anchor sits at the top (or the theme's spDef anchor); write ctr for the model's middle default 2026-10-14 04:03:35 +03:00
Nikita RakitinandCursor 05eb4c611b UI: keep every texture within the GPU's max texture side
Slide show, transition and morph frames, thumbnails, master-view canvas, master/layout
thumbnails, Design Ideas previews, presenter-view preview and video frames all upload through
textures::upload/replace, which shrinks a picture past max_texture_side (egui panicked on one).
Render sizes are fitted to the limit first, so a big screen, extreme DPI or a 1:56 slide
doesn't render a picture the GPU can't hold. The master canvas is cropped like the slide canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 03:57:32 +03:00
Nikita Rakitin 77de43266e Text: shrink on overflow stops at once when even the smallest scale overflows 2026-10-14 03:56:19 +03:00
Nikita Rakitin dac0d054fe Show: See All Slides and zoom
During a show, G opens a thumbnail grid and a click jumps to that slide.
Z then a click zooms the slide to 2×. Presenter view has Black, White,
All slides and Zoom. Esc closes the grid, then the zoom, before it leaves.
2026-10-14 03:53:53 +03:00
Nikita RakitinandCursor ba71a40a39 Canvas: a trackpad swipe turns the page
A sideways flick changes slides when the slide fits the window, and any
flick does so during the show. One gesture turns one page. A zoomed slide
still pans. Pinch-zoom is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 03:48:39 +03:00
Nikita RakitinandCursor 4575d19ac7 UI: Photo Album dialog inserts pictures as slides
Insert › Images opens Photo Album. insert.photoAlbum adds the pictures
and text boxes as slides in the open presentation, one undo step, with
layouts, frames and captions. Omitting pictures opens the dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 03:43:28 +03:00
Nikita Rakitin 3fd1083468 Shapes: chevron text rectangle as presetShapeDefinitions give it 2026-10-14 03:27:58 +03:00
Nikita RakitinandCursor c583480010 Startup: survive damaged settings, font folders, recovery folders and small texture limits
- ui.json / prefs.json are read through a new settings module: regular files only, 4 MiB
  cap, parse errors logged, values sanitized (a saved zoom of 1e30 aborted start-up,
  1e4 panicked in egui); writes are atomic.
- The canvas and the control protocol clamp stray zooms; the status bar shows the zoom in use.
- The canvas texture is cut to the GPU's max texture side (zoom 400% on a 2048 limit panicked).
- The system font scan opens only regular files of sane size (a FIFO named *.ttf in ~/.fonts
  hung start-up), with depth and entry caps.
- AutoRecover skips non-regular entries and reopens at most 32 per start, newest first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 03:27:57 +03:00
Nikita Rakitin 111f029112 Shapes: star4 and star5 text rectangles as ECMA-376 defines them 2026-10-14 03:23:17 +03:00
Nikita RakitinandCursor bd017b861d UI: Size and Position dialog sets geometry in one step
Shape Format › Size and the shape menu open a dialog for position,
size, rotation, scale and lock aspect ratio. One command writes them
together, so undo restores the whole change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 03:20:35 +03:00
Nikita Rakitin 71b02327cf Charts: category axis orientation (maxMin) read, written and drawn; horizontal bars run bottom-up 2026-10-14 03:16:35 +03:00
Nikita Rakitin ca2af8fe30 Render: cap a stroke's dash count so a tiny dash on a huge shape can't abort on open 2026-10-14 03:14:28 +03:00
Nikita Rakitin 8e124d32a8 Charts: clustered bar widths from gap width and overlap as fractions of one bar; schema defaults when absent 2026-10-14 03:09:16 +03:00
Nikita Rakitin cbc6cd0e34 Charts: draw data-point fills on columns and bars, smoothed lines, and the file's chart text colour 2026-10-14 03:03:16 +03:00
Nikita RakitinandCursor e4022e6702 UI: Paragraph dialog sets alignment, indents and spacing
format.paragraphDialog applies alignment, first-line and hanging
indents, spacing and line spacing in one undo step. With no fields it
opens the dialog from the Home › Paragraph launcher. Tabs… opens a
standalone Tabs dialog and returns to Paragraph.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:59:40 +03:00
Nikita RakitinandCursor fea8c1498f Metafile: EMF+ Clear, and the EMF+ record coverage written down
EmfPlusClear paints the whole picture frame in its colour, ignoring the
world transform and clip, as GDI+ does; a transparent clear draws nothing.
With it every drawing record of MS-EMFPLUS 2.3.4 is played. The module docs
list what is deliberately ignored (rendering hints, rendering origin,
serializable image effects, terminal-server records, and StrokeFillPath,
which the specification names but gives no layout for), and the parity docs
no longer say EMF+ isn't drawn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:52:57 +03:00
Nikita RakitinandCursor 5fc8aaee7c Metafile: EMF+ cardinal curves and region fills
DrawCurve, DrawClosedCurve and FillClosedCurve (GDI+ cardinal splines, the
smoothed lines of charts and freehand shapes) draw as cubic Béziers with
the record's tension, offset and segment count; FillRegion fills the region
object's area. These records were skipped, so the shapes were missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:47:47 +03:00
Nikita Rakitin b1ce243806 Spelling: notes right-click menu, Review > Language with Do Not Check Spelling, repeated words 2026-10-14 02:47:34 +03:00
Nikita Rakitin a4ed08b687 SmartArt: layout thumbnails in galleries and live preview on hover 2026-10-14 02:47:28 +03:00
Nikita RakitinandCursor 68ab3a4839 UI: Font dialog sets character formatting in one step
format.fontDialog applies family, size, bold, italic, underline, colour,
strikethrough, baseline, caps and spacing together, so one undo restores
them. With no fields it asks the UI to open the dialog, seeded from the
selection. Home › Font, the shape menu and Cmd+T open it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:41:28 +03:00
Nikita RakitinandCursor 33252f28db SmartArt: Convert to SmartArt gallery on Home, Convert to Text keeps the text size
Home > Paragraph gets a Convert to SmartArt button whose menu lists every layout by category (List, Process, Cycle, Hierarchy, Relationship, Matrix, Pyramid, Picture); a click converts the selected text. Convert to Text sets the text box's titles and bullets to the sizes the graphic showed them at. Icon toggles carry their tooltip as their accessible name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:34:55 +03:00
Nikita Rakitin 8bc7e55561 Spelling: squiggles in table cells and notes; UI-thread checking on the web 2026-10-14 02:34:33 +03:00
Nikita RakitinandCursor 304bf06290 Insert: Lucide icon gallery as stroked shapes
Insert › Icons places seventy Lucide 1.56.0 stroke icons as custom
geometry: no fill, round caps, and a line width scaled from the
24-unit design. The gallery searches by name. SVG paths, including
arcs, are converted when the icon is inserted so the renderer strokes
the outline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:31:54 +03:00
Nikita RakitinandCursor 7764c27cc4 SmartArt: Name and Title org chart, closer Half Circle, ten more layouts, convert to and from text and shapes
Name and Title Organization Chart with a title box per node (DiagramNode.title, smartArt.setNodes title; written as the title box's presentation point text and read back). Half Circle Org Chart draws two half rings per node. New layouts: Vertical Box List, Trapezoid List, Table List, Accent Process, Continuous Block Process, Basic Bending Process, Linear Venn, Stacked Venn, Nested Target. smartArt.fromText (Convert to SmartArt), smartArt.toText and smartArt.toShapes, with a Convert menu in SmartArt Design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:26:58 +03:00
Nikita RakitinandCursor b4c03f0a75 SmartArt: organisation charts with assistants, segmented and multidirectional cycles, cycle matrix, segmented pyramid, pie
Organization Chart and Half Circle Organization Chart with elbow connectors; assistant nodes (Add Assistant, written as asst points and read back) sit beside their parent on a row of their own. Segmented Cycle, Multidirectional Cycle, Cycle Matrix, Segmented Pyramid and Basic Pie. Venn labels move further out so the third circle's no longer runs into the overlap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:16:40 +03:00
Nikita RakitinandCursor 06c275d136 Animations: check effect options and accept direction words
animation.add stored any `option` string as given, and the renderer reads
direction codes letter by letter, so "fromBottom" (r, t, b) flew in from the
bottom right. The option is now checked against the effect's list; direction
words ("fromBottom", "topLeft", "upRight") map to the stored codes ("b", "lt",
"ru"), and anything else is an error listing the valid options.
animation.options applies the option to each target in its own effect's terms
and leaves targets without such an option alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:15:42 +03:00
Nikita RakitinandCursor eb7e346ea1 Text: underline an IME composition in a table cell
The preedit was already stored in the cell. It is now laid out the same
way the cell is drawn, and the editor underlines it in place, thicker
under the part being converted. The caret and the input method's
candidate window sit in that cell. Notes stay an egui text field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:08:14 +03:00
Nikita RakitinandCursor 00dd069678 SmartArt: six more layouts, clear Venn labels, read back older Picture Accent Process files
Text Cycle, Continuous Cycle, Horizontal Hierarchy, Grid Matrix, Gear and Funnel. Venn labels sit further out, clear of the overlaps. Files where Picture Accent Process was written as pList2 (with DeckCraft's process-category layout part) import as Picture Accent Process again. Round shapes' text shrinks to the inscribed width.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:07:51 +03:00
Nikita Rakitin a29e200a4e Spelling: background squiggles, Add to Dictionary, per-run languages, compounds 2026-10-14 02:05:22 +03:00
Nikita RakitinandCursor c5a63fc8b7 SmartArt: node pictures from the data model, Brick Scene camera, four more layouts
Node pictures are written on the data model's presentation points (blip through the data part's rels) and read back into the outline on import. Picture Accent Process is hProcess10, not pList2 (checked against PowerPoint's layout list); pList2 is now Horizontal Picture List. Brick Scene looks through an isometric camera. New layouts: Horizontal Picture List, Basic Timeline, Basic Venn, Basic Target.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 02:02:42 +03:00
Nikita RakitinandCursor 6f19e0bd07 SmartArt: shrink text to fit, picture layouts, 3-D styles and more colours
Each shape's text shrinks so its longest word fits on a line (the pyramid tip no longer breaks words). Three picture layouts (Vertical Picture List, Picture Caption List, Picture Accent Process) with a picture placeholder per node and smartArt.picture to fill it. Five 3-D SmartArt Styles (Polished, Inset, Cartoon, Powder, Brick Scene) drawn with lit bevels, and Colorful Range and Gradient Range colours; all written to and read from the .pptx quickStyle and colors parts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:56:36 +03:00
Nikita RakitinandCursor 1a0ffc6413 Draw: pen pressure scales ink width
A pen reading of 1 is the full pen width. A lighter touch draws a thinner
stroke, and a zero reading stays visible at 8% of the pen. A mouse, a
missing or non-finite reading, and a stroke whose samples are all the old
placeholder 0.5 still draw at the pen width. PPTX keeps one width per
stroke: the mean of the samples for a pressured stroke, the pen width for
a legacy one. Tilt is not read.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:56:23 +03:00
Nikita Rakitin ec1f25e617 Review: spelling with Hunspell dictionaries, suggestions, Change and Ignore All 2026-10-14 01:52:53 +03:00
Nikita RakitinandCursor f894bb1d17 SmartArt: keep pyramid text inside its band
Inset each pyramid band's text past the band's mid width so bullets no longer hang over the sloped sides; capped so the tip band still has room.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:47:15 +03:00
Nikita RakitinandCursor 1866de9895 SmartArt: 15 layouts, SmartArt Styles and Change Colors
Layouts now include Vertical and Horizontal Bullet List, Basic Block List,
Basic/Chevron/Vertical/Step Up Process, Basic and Block Cycle, Basic Radial,
Basic and Inverted Pyramid (bands drawn as polygons so the slope and the
text stay right), Basic and Titled Matrix, and Hierarchy. Each layout says
which nodes each shape shows, so typed text maps back for any layout.

SmartArt Styles (flat, subtle, intense, white outline) and Change Colors
(colourful or one accent) are kept on the diagram, offered in the SmartArt
Design tab, and written to .pptx as quick style / colour definition ids
(and read back from them).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:42:52 +03:00
Nikita Rakitin 7e4e628b13 Render: omit the moving-average R² label
PowerPoint does not show R² for a moving average, and the value can be
negative. The line is still drawn.
2026-10-14 01:32:31 +03:00
Nikita RakitinandCursor c176d42bb4 Charts: polar radar, stock high-low lines and up/down bars, combo sample
Radar and filled radar charts are drawn polar: spokes per category,
polygon gridlines and value labels up the first spoke. Stock charts
draw high-low lines with close dashes, or up/down bars between open
and close, and are saved as c:stockChart (hiLowLines, upDownBars)
instead of a line chart; c:upDownBars is read, and chart.options takes
upDownBars. New stock charts start as high-low-close and new combo
charts as columns with a line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:31:40 +03:00
Nikita Rakitin 768641615a Render: draw chart trendlines
Fit linear, exponential, logarithmic, polynomial, power and moving-average
lines, including forecast, and draw them with the equation and R² label.
2026-10-14 01:29:51 +03:00
Nikita RakitinandCursor b03b19bca7 SmartArt: write the outline to .pptx as a diagram; read it back from the data model
Export: a group with a diagram becomes a graphicFrame naming ppt/diagrams
data, layout, quickStyle and colors parts, plus the group's shapes as the
cached drawing (dsp). The data model is built from the outline (doc, node,
parTrans/sibTrans points and parOf links). A diagram read from a file keeps
its own layout, quick style and colour parts until its layout is switched.

Import: the outline is rebuilt from the data model's parOf tree, so
PowerPoint diagrams get the Text Pane. Their shapes stay the file's drawing
until the first outline edit (typed text isn't mapped back to nodes then).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:29:49 +03:00
Nikita Rakitin ad7af60510 render: 3-D contours 2026-10-14 01:29:23 +03:00
Nikita RakitinandCursor 210df9a3d1 Charts: keep and draw region map charts
Read the regionMap chartEx layout as a RegionMap chart (regions and
their colour values), write the part back as read while unchanged, and
when edited rebuild it with the original geography (projection, culture
and cached boundaries) carried over. With no map data in the repo, the
renderer draws each region as a value-coloured tile with a colour scale
and a note that the map itself is not drawn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:23:54 +03:00
Nikita Rakitin ab6051894d render: 3-D light rigs and materials; extrusion sides lit per side 2026-10-14 01:23:54 +03:00
Nikita RakitinandCursor 6e40335424 SmartArt: Text Pane, add and remove shapes, promote and demote
A SmartArt group now keeps its outline (Shape.diagram: layout + nodes with
levels) and its shapes are regenerated from it inside the group's child
rectangle. New commands: smartArt.nodes, setNodes, addNode (after, before,
above, below), removeNode, promote, demote and layout. Sub-points become
bullets in their parent's shape; the hierarchy layout is a real tree with
connector lines. Text typed into a diagram shape is read back first.

UI: a SmartArt Design contextual tab and a Text Pane (Enter adds a line,
Tab/Shift+Tab demote/promote, Backspace on an empty line removes it).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:22:22 +03:00
Nikita Rakitin 27988aaa24 Render 3-D extrusions: sides swept behind the face under turned cameras
sp3d extrusionH (with extrusionClr, srgb or scheme) under a camera that
turns the shape now draws the shape's sides: its outline swept from the
back face to the front along the camera's view of the depth axis, in the
side colour shaded, before the face. camera::project3 projects points
off the shape's plane.
2026-10-14 01:19:18 +03:00
Nikita RakitinandCursor b3420a0b04 Charts: multi-level treemaps and sunbursts; inclusive box quartiles
Read, keep and write every cx:lvl of a chartEx category dimension,
build a category tree from runs of parent labels, and draw treemaps as
nested squarified groups with banners and sunbursts as one ring per
level. Box & whisker honours quartileMethod="inclusive" (QUARTILE.INC)
on import, render, save and the chart.options command.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:16:09 +03:00
Nikita RakitinandCursor 0cbee85ea8 File: six original starter decks
File › New and the start screen offer Briefing, Lesson, Pitch, Field Notes,
Quarterly and Evening Talk. Each is a few slides of our own text on one of
our themes. file.new takes template, and file.templates lists them. Passing
a theme as well, or blank, is refused. PowerPoint's large gallery is still
open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:12:38 +03:00
Nikita Rakitin ec725a4e6b Notes Page view: draw master text wrapped, as the PDF does
pdf::master_text_pieces turns the text engine's layout of the notes
master's header, date, footer and page number into per-run, per-line
pieces scaled to the page; the Notes Page view paints them in their
sizes and colours. Stacked on #583.
2026-10-14 01:10:03 +03:00
Nikita RakitinandCursor 15daed6ca4 Charts: histograms, Pareto and box and whisker; Office 2016 charts save as themselves
Histograms and Paretos bin their observations (bin count, bin width or
Scott's rule) and draw as touching columns, a Pareto with its cumulative
line on a percentage axis. Box and whisker charts draw exclusive-quartile
boxes, 1.5 IQR whiskers, means and outliers per category group.

Edited and new Office 2016 charts are written as chartEx parts from the
model (with the plain-chart fallback), so a waterfall stays a waterfall.
The chart menus list all seven types, and chart.options sets bins and
waterfall totals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:08:13 +03:00
Nikita Rakitin 8fd04ebd92 PPTX: round-trip chart trendlines
An owned corpus slice checks combo charts with data labels, merged
tables with diagonal borders, and text columns, autofit, vertical text
and a WordArt warp. Trendlines (type, order, period, forecast,
intercept, equation and R²) are now read and written.
2026-10-14 00:58:20 +03:00
Nikita Rakitin 9f8c860f90 PPTX corpus: themes, masters, effects and links; keep text Mouse Over actions
New owned corpus test on PowerPoint-shaped XML: a custom-theme deck with
shadow, glow, soft-edge and reflection effects and every common text and
shape hyperlink, and a two-master deck with its own themes and layouts.
The one loss it found: a:hlinkMouseOver on text runs was dropped; it is
now read into RunProps::hover and written back.
2026-10-14 00:52:25 +03:00
Nikita RakitinandCursor 8f935a8697 Metafile: EMF+ arcs, convex clip paths, wrapped strings, nested metafiles
Arcs and pies; clip paths and regions clip to their convex outline instead
of the bounding rectangle; DrawString wraps to the layout width; glyph-index
driver strings in the Windows core fonts map back to text; metafile images
draw their nested picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:52:06 +03:00
Nikita RakitinandCursor afd058a8ff UI: sections in the Slide Sorter
The sorter draws a bar for each section. Add Section splits at the current
slide, double-click renames in place, the chevron collapses that section, and
dragging the bar moves the section and its slides together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:50:34 +03:00
Nikita Rakitin 11e63e31e1 Lay out master header/footer text with the text engine in PDF pages
Notes pages and handouts lay out the master's header, date, footer and
page-number placeholders with deckcraft-text (render::place_master_texts),
so their text wraps in the placeholder and keeps mixed run formatting,
drawn as visible glyphs. Stacked on #580.
2026-10-14 00:44:09 +03:00
Nikita RakitinandCursor 07d12ed8d9 Metafile: EMF+ images, text, gradient brushes and clipping
Builds on the EMF+ only reader: bitmap images (raw pixel formats and
compressed PNG/JPEG payloads) through DrawImage and DrawImagePoints,
DrawString and character-code DrawDriverString, hatch, texture, linear and
path gradient brushes, clip rectangles, paths and regions, and objects
split across several records.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:43:00 +03:00
Nikita Rakitin 32fcaec293 Preview the slide in Notes Master view; print master text in its own style
Notes Master view draws the current slide inside the slide-image
placeholder. Header, date, footer and page-number text on notes pages
and handouts (PDF and the Notes Page view) uses the placeholder's
resolved font, size, weight, slant and colour, scaled with the page.
Stacked on #577.
2026-10-14 00:40:09 +03:00
Nikita Rakitin 6fcf392370 Lay out notes pages and handouts from their masters
PDF notes pages and the Notes Page view put the slide (keeping its aspect)
and the notes where the notes master's slide-image and body placeholders
are, scaled from the notes size to the paper, and print the master's
header, date, footer and page number. PDF handouts print the handout
master's. Decks without these masters keep the old layout. Stacked on #573.
2026-10-14 00:35:26 +03:00
Nikita Rakitin c94b5c8d1e PPTX: round-trip 3-D models and animation bounce end
An owned corpus opens, saves and reopens SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink, .glb models and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE, playback options and rewind stay with their open PRs.
2026-10-14 00:35:12 +03:00
Nikita RakitinandCursor 2c15fcc033 Metafile: draw EMF+ only pictures
EMF+ only files (no GDI fallback) used to render as empty pictures. Play
their vector records: solid brushes and pens, paths, rects, polygons,
lines, ellipses and Beziers under world/page transforms with save/restore
and containers. Dual files keep drawing their EMF records.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:32:30 +03:00
Nikita Rakitin 443fe300d0 Add the Notes Master view
View ▸ Notes Master edits the notes master on a notes-size page (creating
PowerPoint's default placeholders if the deck has none); its tab toggles
header, date, slide image, body, footer and page number, sets the
background and closes the view. The PPTX writer now writes an edited
notes master as it is, even in a deck without notes. Stacked on #572.
2026-10-14 00:31:14 +03:00
Nikita Rakitin bba398f60f Add the Handout Master view
View ▸ Handout Master opens the handout master (creating one with header,
date, footer and page-number placeholders if the deck has none) on a page
at the notes size, with a dashed preview of where 1-9 slides go. Its
contextual tab toggles the placeholders, sets the background and closes
the view. Stacked on #533, which reads and writes the handout master.
2026-10-14 00:25:38 +03:00
Andreas Haraminac 641fa36a8e Media.seek: preserve decoded frames and EOF across video seeks 2026-10-13 21:24:26 +00:00
Nikita RakitinandCursor 4b2548c99b Text: Tabs dialog and the ruler's tab selector
Format > Tabs opens from Home > Line Spacing > Tabs…, from the
Paragraph dialog, or by double-clicking a tab stop on the ruler. It
lists the selected paragraphs' tab stops (inches) with their
alignment. Set adds or replaces a stop at a position with Left,
Center, Right or Decimal alignment, Clear removes one, Clear All
removes them all, and you can change the default tab spacing. OK
applies everything in one undo step through format.tabs, which now
takes `set`. Cancel changes nothing.

The ruler's corner has PowerPoint's tab selector. A click cycles left,
center, right and decimal, and a click on the ruler then sets that
kind of stop. Presses on the rulers no longer reach the slide, so
they don't end text editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:22:16 +03:00
Nikita RakitinandCursor b89c9cafd7 UI: preview slide animations on the editor canvas
Animations › Preview and Animation Pane › Play All play the current
slide on the canvas instead of opening the slide show. Escape or a
click stops playback. Transitions › Preview still opens the show.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:21:42 +03:00
Nikita RakitinandCursor 8a36a6990e View: indent and tab markers on the ruler
While you edit text, the horizontal ruler now shows the text area's
span and the indent and tab markers of the paragraph with the caret.

- First-line indent: the top triangle.
- Hanging indent: the bottom triangle. Dragging it moves the left
  indent while the first line stays put.
- Left indent: the box under the hanging triangle. Dragging it moves
  both.
- Tab stops are drawn as left, centre, right and decimal marks.
- A click on the ruler adds a left tab, dragging a tab moves it, and
  dragging a tab down off the ruler removes it.

Every change goes through a command (format.paragraph or the new
format.tabs). Each is one undo step and applies to the selected
paragraphs, and text editing goes on throughout. format.ruler is a
new query that reports the text area's position and the paragraph's
resolved indents and tabs. Rotated and vertical text show no markers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:13:17 +03:00
Nikita Rakitin c52b3142c4 Make PDF export ~2.8x faster without changing its bytes
krilla compressed each slide's print-resolution artwork on the export
thread; with its rayon feature (desktop only) the deflate runs in the
background while the next slide renders. The rendered pixels are also
moved into the image instead of copied.
2026-10-14 00:13:14 +03:00
Nikita Rakitin 5bdfc3228d Add a performance baseline: deckcraft-cli bench and cargo xtask bench
Times open, per-slide render, save, the PPTX round trip and PDF export, and
reports peak memory; xtask builds the CLI in release, runs it on the sample
and any given decks, and compares with docs/benchmarks/baseline.json.
2026-10-14 00:05:47 +03:00
Nikita RakitinandCursor 88bea399e5 UI: customizable Quick Access Toolbar
The title bar's quick access buttons become a list the user controls:
a Customize menu with PowerPoint's common commands and Reset, right-click
any ribbon button to add it, right-click a toolbar button to remove it.
The toolbar is saved with the UI state; app.qat scripts it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 00:04:51 +03:00
Nikita Rakitin 184b76e2a7 Round-trip text effects, layout, fills, bevels and connectors
An owned corpus of those kinds survives open, save and reopen. A run's
inner shadow, reflection and soft edge were dropped, and a bevel preset
with no kept 3-D XML was not written back, so import and export now keep
them.
2026-10-14 00:04:22 +03:00
Nikita RakitinandCursor b763579d94 View: movable drawing guides saved with the presentation
View > Guides used to paint two fixed centre lines. Guides now belong
to the presentation (DrawGuide, read from and written to p:guideLst in
viewProps.xml, in eighths of a point), so they survive a save and
reopen in PowerPoint.

- Turning Guides on gives a deck without guides PowerPoint's two
  centre guides.
- A guide can be dragged anywhere away from shapes. It moves in whole
  points, unless Alt is held or snapping is off, and shows its
  distance from the centre while it moves.
- A guide dropped off the slide is deleted.
- The slide context menu's Grid and Guides submenu has Add Vertical
  Guide and Add Horizontal Guide.
- While guides are shown, moving and resizing shapes snaps to them.

New commands guide.add, guide.move and guide.delete are undoable;
guide.list is a query.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:55:59 +03:00
Nikita RakitinandCursor f0be1aa767 Edit: Paste Special (destination theme, keep source, picture, text only)
Adds edit.pasteSpecial. Keep Source Formatting bakes the copied shapes'
theme colours and theme fonts into fixed values (captured at copy time),
Picture draws the copied shapes into one PNG picture, and Keep Text Only
pastes plain text. Ctrl+Alt+V, Edit › Paste Special… and the Paste menu
open a Paste Special dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:55:45 +03:00
Nikita RakitinandCursor 4dbea3256f UI: View › New Window opens a second window
The editor had one window, so looking at another slide meant leaving
the one you were editing. View › New Window opens one more window on
the same presentation. That window keeps its own slide and shows edits
from the first window. Closing it leaves the presentation open.
Arrange All is not included.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:52:40 +03:00
Nikita RakitinandCursor d500d045b5 Insert: Action Settings dialog and shape.action
Shapes could only get an action from an action button preset or a
hyperlink to a URL or slide. Insert > Action > Action Settings… opens
PowerPoint's dialog with Mouse Click and Mouse Over tabs: None,
Hyperlink to (next, previous, first, last, last viewed, end show, a
custom show, a slide or a URL), Run program, Play media, and Highlight
click, starting from the shape's current setting.

The new shape.action command sets or clears the click or hover action
of the selected shapes and checks slides, custom shows and required
arguments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:45:04 +03:00
Nikita RakitinandCursor 4ba7deb5ff Text: Find and Replace across slides, tables and notes
Adds text.find (match case, whole words; matches in shape text, table
cells and speaker notes) and text.replace (one match or all, as one
undo step, keeping the first run's formatting). Home gets Find and
Replace buttons, Ctrl+F / Ctrl+H and Edit › Find… / Replace… open a
Find and Replace dialog that steps through matches slide by slide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:44:40 +03:00
Nikita Rakitin f9d5a43174 Round-trip comments, links, shows, groups and inherited placeholders
An owned corpus of those element kinds survives open, save and reopen.
Paragraph default run properties, including a link, and action sounds
were dropped on save, so import and export now keep them.
2026-10-13 23:43:03 +03:00
Nikita RakitinandCursor 0e763ab78c Text: Bullets and Numbering dialog
The Bullets and Numbering menus only offered a fixed list of styles.
"Bullets and Numbering…" at the foot of both menus opens PowerPoint's
dialog: Bulleted and Numbered tabs with their styles, a custom bullet
character, Start at, Size (% of text, 25-400) and Color (automatic,
text or an accent), and None.

format.numbering now takes color and size like format.bullets, and
both clamp the size to PowerPoint's 25-400 %.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:39:22 +03:00
Nikita RakitinandCursor d4175d2902 Animations: add 34 PowerPoint motion path presets
Adds Basic (stars, heart, crescent moon, teardrop, ...), Lines & Curves
(zigzag, waves, S curves, spirals, bounces, ...) and Special (figure 8,
bean, peanut, neutron, ...) motion paths with PowerPoint preset ids, so
they appear in the Motion Paths gallery, play back, and round-trip
through PPTX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:37:00 +03:00
Nikita RakitinandCursor bc42b62cc4 Pictures: Remove Background with a classical matte
The Picture Format > Remove Background button was a disabled
placeholder. picture.removeBackground now flood-fills from every edge
pixel through similar colours: each step must be close to the pixel
before it (so gradients and soft shadows follow) and to the edge colour
it started from (so it can't leak into the subject). That region turns
transparent with a one-pixel soft edge, and the picture points at a new
PNG; undo brings the original back. Tolerance 0-100 (default 20); the
ribbon offers normal, looser and tighter. Pictures whose edges are all
subject are left alone, and pictures over 16 megapixels are refused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:35:25 +03:00
Nikita RakitinandCursor 1b326e2e87 Review: Thesaurus pane with a built-in English word list
Review > Thesaurus (Shift+F7) opens a Thesaurus pane on the selected
word, or on the word at the caret. Clicking a synonym while editing
text puts it in place of that word in one undo step, and the pane
then looks up the new word. You can also type a word into the pane to
look it up.

The word list is written for DeckCraft and has no third-party source:
about 260 senses of common presentation vocabulary in
engine/data/thesaurus.txt. It is embedded, so the Thesaurus also
works offline and in the browser build. Lookups try simple English
endings and keep the word's capitalisation. New commands:
review.thesaurus (a query) and review.replaceWord. Ribbon big buttons
now carry an accessibility label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:34:30 +03:00
Nikita RakitinandCursor 36acf26f1d Pictures: Recolor gallery and Set Transparent Color
The model, renderer and PPTX already handled duotone, black-and-white,
grayscale and a transparent colour, but the Color menu offered only
saturation and grayscale. picture.recolor applies PowerPoint's Recolor
presets (No Recolor, Grayscale, Sepia, Washout, Black and White 25/50/75%,
and dark and light variations of Text 2 and the six accents) replacing
the previous recolour; picture.recolors lists them. picture.transparentColor
makes a colour transparent or clears it. The Color menu shows a Recolor
gallery of preview ramps and a Set Transparent Color colour picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:30:59 +03:00
Nikita RakitinandCursor c4c56a6a8b Pictures: Picture Styles gallery and Picture Border
Picture Styles was a menu of six names, each running two or three
separate commands that left the previous style's effects behind. New
picture.style applies one of PowerPoint's 24 picture styles (frames,
shadows, soft edges, reflections, bevels, rounded, snipped and oval
shapes, a rotated frame) as one step that replaces the whole previous
look; picture.styles lists them and "none" clears the style. Picture
Format shows them as a thumbnail gallery, and a Picture Border split
button sets outline colour, weight and dashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:27:32 +03:00
Nikita RakitinandCursor 427a955c06 Tables: Draw Table pencil and Table Eraser
Insert > Table > Draw Table turns on a table pencil. Dragging a box on an
empty part of the slide makes a one-cell table. Drawing a line inside a
table splits the cells it crosses, across or down, and adds a row or
column only where needed, so the cells it doesn't cross stay whole.
Table Design > Draw Borders has the pencil and a Table Eraser. A click
on a border merges the two cells on either side when together they
form a rectangle, and their text is kept.

The new table.drawLine and table.eraseLine commands are each one undo
step. The grid work is pure (engine/src/drawtable.rs). It rebuilds the
table from its anchor cells, so existing merges survive, and it caps
the grid at 200 rows and columns. Ribbon big buttons now carry an
accessibility label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:26:51 +03:00
Nikita RakitinandCursor 5a060c8b48 Design: Variants gallery with four colour variations of the theme
PowerPoint's Design tab shows the current theme in four variants. The
Variants group had only the Colors and Fonts menus. theme_variants builds
four colour schemes from the theme's built-in colours (as designed, two
accent rotations, and a dark variant with light and dark swapped), so
switching between variants doesn't drift. design.variant applies one to
the master; design.variants reports them and which is applied. The
Design tab shows them as a gallery before Colors and Fonts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:22:31 +03:00
Nikita RakitinandCursor c22747b104 Morph: the same text moves and resizes instead of cross-fading
A title typed as "TEST" on a title slide and on a content slide pairs
the two titles, but they differ in placeholder, size and alignment, so
Morph with the default Objects option cross-faded them: the old title
rose and faded out while the new one faded in.

Paired shapes whose text is the same now use the word morph (shared
words travel and resize from their old place to their new one) whatever
the Morph option, as PowerPoint does. Different texts still cross-fade
with Objects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:22:19 +03:00
Nikita RakitinandCursor f3ca5a63b9 Table: borders, cell margins and cell text alignment
The model, renderer and PPTX already carried cell borders, diagonals,
margins and vertical alignment, but nothing set them. New commands:
table.borders (all, outside, inside, inside horizontal/vertical, each
side, diagonals or none, with pen colour, width and dash; the facing
side of a neighbouring cell follows, as in PowerPoint), table.cellMargins
(Normal, None, Narrow, Wide or per side) and table.cellAlign. They act on
the given cells, the selected range, the cell with the caret, or the
whole table. Table Design gains a Borders menu with pen weight, style and
colour; Table Layout's Top/Center/Bottom now align cell text (they set
the text body's anchor, which cells override) and gain Cell Margins.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:17:51 +03:00
Nikita RakitinandCursor 4658042de8 Draw: Ink to Shape converts pen strokes into shapes
Draw > Ink to Shape recognises each pen stroke on the slide's ink layer
as a line, rectangle, oval or isosceles triangle (apex up or down) and
replaces it with that preset shape, outlined in the stroke's colour and
width with no fill. Strokes it can't recognise, and highlighter strokes,
stay ink. The ink shape is removed once it has no strokes left. The
whole conversion is one undo step.

The recogniser (engine/src/inkshape.rs) is pure geometry: ends meeting,
area covered within the bounding box, and spread from the fitted
ellipse. Ribbon big buttons now carry an accessibility label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:16:49 +03:00
Nikita RakitinandCursor 0d47da07e5 Charts: draw waterfall, funnel, treemap and sunburst layouts
These Office 2016 charts were drawn as plain columns. Now:
- waterfall: bars float from the running total, total points
  (cx:subtotals, now read into Chart.subtotals) stand on the axis and
  reset it, increases/decreases/totals take accents 1-3 with a matching
  legend, and connectors join each bar to the next;
- funnel: centred bars as wide as their value, category labels on the
  left and values inside;
- treemap: squarified tiles in proportion to their value, labelled;
- sunburst: the innermost level as a ring with an open centre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:16:23 +03:00
Nikita RakitinandCursor 37559f793c UI: Grid Options sets spacing, snap and the on-screen grid
View › Grid Options sets the grid spacing, snap to grid, snap to
other objects, and whether gridlines and guides are drawn. The
canvas grid follows that spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:14:52 +03:00
Nikita RakitinandCursor 5c1432b362 PPTX: keep custom XML, the sensitivity label and custom properties
Decks from SharePoint and Microsoft 365 carry customXml items (document
library metadata and content types), docMetadata/LabelInfo.xml (the
Microsoft Purview sensitivity label) and docProps/custom.xml. All were
dropped on save, so a labelled corporate deck came back unlabelled. They
are now kept verbatim with their content types, their own relationships
(itemProps) and the root or presentation relationship linking them. Kept
parts never replace a part the writer makes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:12:18 +03:00
Nikita RakitinandCursor 3f31f9951c PPTX: read Office 2016 charts (waterfall, funnel, treemap…) and keep them
Waterfall, funnel, treemap, sunburst, histogram, Pareto and box &
whisker charts live in a chartEx part behind an mc:AlternateContent
choice requiring cx1. DeckCraft took the fallback, usually a text box
saying the chart isn't available, so these charts were lost on open
and on save.

The cx1 choice is now taken for chartEx frames and the part is read
into the chart model: categories (innermost level), values, number
format, series name, title, legend and data labels. Unchanged charts
are written back as their chartEx part in a cx1 choice with a plain
chart of the same data as fallback, as PowerPoint writes them; an
edited one is saved as a plain chart. Region maps are not read.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:08:56 +03:00
Nikita Rakitin 034b312a64 Round-trip tables, charts, animation, media, sections and masters
An owned corpus of those deck kinds survives open, save and reopen. The
check found the handout master — printed header, footer, date and slide
number — was dropped on save, so import and export now keep that part.
2026-10-13 23:08:31 +03:00
Nikita RakitinandCursor e8a383effa PPTX: keep add-in tags on the presentation, slides and shapes
p:custDataLst points at ppt/tags/tagN.xml parts that add-ins such as
think-cell, Mekko Graphics and Office Timeline use to recognise their
decks, slides and shapes. They were dropped on open, so a saved deck lost
its add-in identity. The model now carries name/value tags on the
presentation, slides and shapes; the reader resolves every tags part and
the writer emits one tags part per holder, in the schema's element order.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:07:50 +03:00
Nikita RakitinandCursor 30c2fe4b6d Design: Browse for Themes from a .thmx, .pptx or .potx file
deckcraft_pptx::import_theme reads the theme of a .thmx (its main document is
a theme part) or of a presentation's first master, with the media its format
scheme uses. design.browseTheme {path | name, data} applies it to the current
master as one undo step, giving the theme's pictures ids of this deck.

Design tab: Browse for Themes… next to the theme gallery and in the Slide
Master Themes menu; the desktop picker offers Office Theme (.thmx) and
presentations. Ribbon small buttons expose their label to accessibility (same
change as #512). Parity row Browse themes M → D.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:04:41 +03:00
Nikita RakitinandCursor 24622bf94d Format: bound total embedded media when opening a .deckcraft
Only each member was capped (2 GiB), so a compact archive with many
media items, or one large member listed under many items, could
inflate without limit and exhaust memory while opening.

Loading now caps all embedded media together (4 GiB) and the number
of media items (100,000), refuses a member before inflating it when it
declares more than is left, and keeps one item per media id, so a
repeated id inflates its member once and the deck can still be saved
(a zip can't hold two media/<id> entries).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:00:17 +03:00
Nikita RakitinandCursor 3489c3b22d Format: report damaged embedded media and open such files as a repaired copy
load() ignored every failed media read, so a .deckcraft with a missing,
oversized or bad-CRC media member opened silently with empty pictures,
and the next Save overwrote the original with that loss.

load_checked() now lists each unreadable member (linked media saved
without bytes is not damage). file.open/openBytes return damagedMedia,
show a message, and open the deck as an untitled "(Repaired)" copy so
Save asks for a new name and the original file stays intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:55:52 +03:00
Nikita RakitinandCursor 5acf9eeebf Review: compare with a revised copy and accept its slides
review.compare {path | name, data} reads a revised presentation (native or
PPTX) and lists slide-level revisions against the open one: inserted,
deleted and changed slides, with what changed (text, text formatting,
moved, resized, rotated, formatting, picture, objects inserted/deleted,
layout, notes, transition, animations, background, hidden). Slides match by
id, or by position when the decks share none (fixed for the session so
accepting doesn't switch the matching half way). Shapes match by id, then
name.

review.accept {index | all} applies revisions as one undo step: a revised
slide's media is copied (remapped on an id clash), its shape ids kept unless
another slide uses them (animations follow), its layout matched by id, then
name. review.revisions and review.endCompare complete the set.

Review tab: Compare (file picker), Accept All, End Review; the Revisions pane
lists changes with Go-to and Accept. Parity row: Compare presentations M → P.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:49:55 +03:00
Nikita RakitinandCursor 10ad8d7ba7 UI: keep editor slides on the Retina pixel grid
The editing canvas, thumbnails and layout previews were drawn between
physical pixels, so a linear filter blurred small text. The slide show
draws much larger glyphs, which is why presenting still looked sharp (#184).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:44:42 +03:00
Nikita RakitinandCursor e615188f8b Text: AutoCorrect options, case rules and your own entries
AutoCorrect gains PowerPoint's options: correct TWo INitial CApitals,
capitalize the first letter of sentences and the names of days, fix
accidental cAPS LOCK, and replace 1/2, 1/4, 3/4 with fraction
characters. "word..." becomes "word…", "word," and "word." are corrected
too, and the built-in list grows from 13 to ~70 entries. The case rules
apply only to typing, not pasted text.

New text.autocorrect sets the options and adds or deletes replace
entries (your own win over the built-in list; deleted built-ins stay
deleted) and returns the list. Preferences ▸ AutoCorrect Options… opens
an AutoCorrect dialog with the options and the editable list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:43:32 +03:00
Nikita RakitinandCursor 5007c6919e Insert: Summary, Section and Slide Zoom with Return to Zoom
Insert › Links gains Zoom, with PowerPoint's three kinds: Slide Zoom
(pictures of chosen slides on the current slide), Section Zoom (one per
chosen section) and Summary Zoom (a new Title Only slide in front of
the chosen parts). Each zoom is a picture of its target that jumps to
it in the show. With Return to Zoom, on by default for section and
summary zooms as in PowerPoint, the show comes back to the zoom slide,
fully built, after the target slide or the section's last slide; going
elsewhere (a slide number, See All Slides) forgets it. Hyperlink gains
zoom_return for this; .pptx saves zooms as the linked pictures
PowerPoint writes as their fallback, so the return flag is DeckCraft-
only for now. Live thumbnails, the zoom-in animation and the native
p166:sldZm element are still missing; gaps.md says so.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:40:37 +03:00
skylar taylor-barrick aeaa3684a8 TEXT-TEST: require actual Symbol character coverage 2026-10-13 13:39:33 -06:00
skylar taylor-barrick c536c6e905 PDFW2: adopt shared PDF writer with explicit embedding errors 2026-10-13 13:39:33 -06:00
skylar taylor-barrick 7d5dd85209 UI-FONTS: continue fallback selection past ineligible faces 2026-10-13 13:39:33 -06:00
Nikita RakitinandCursor a7bc932cc8 UI: WordArt adjust handles drag a text warp
A selected text warp shows one yellow handle per adjust.
Dragging it sets that guide value in one undo step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:38:16 +03:00
Nikita RakitinandCursor 9af5a7d17b Pictures: Artistic Effects — apply, change, remove and keep in PPTX
New picture.artisticEffect applies one of 7 effects (blur, pencil
grayscale, line drawing, film grain, cutout, glow edges, photocopy) to a
picture's pixels, as PowerPoint does: the processed PNG becomes the
picture and the original is kept on PictureFill::artistic, so another
effect starts from the original and `none` (or Reset Picture) restores
it. Change Picture drops the effect.

PPTX: written and read as PowerPoint does, the blip being the processed
picture with the original and the effect name in
a:extLst/a14:imgProps/a14:imgLayer, so a deck from PowerPoint keeps its
effect, original and all, when saved again.

Picture Format gets an Artistic Effects menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:35:55 +03:00
Nikita RakitinandCursor 6a85afa236 Pptx: round-trip an owned corpus and keep notes-master text
Open, save and reopen decks we author, and compare package parts and
text. The check showed a notes master's header and footer were replaced
by an empty notes master on save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:34:29 +03:00
Nikita RakitinandCursor 69a4424e75 Transitions: built-in sounds, Stop Previous Sound and Loop Until Next
Follow-up to the transition sound picker. The Sound box now lists
[Stop Previous Sound] and six built-in sounds (Chime, Click, Camera,
Whoosh, Drum Roll, Laser) that are synthesised as small WAVs when
chosen, so no sound files ship with the app; the same built-in sound is
embedded once however many slides use it. Loop Until Next Sound repeats
a sound in the show until another slide's sound or Stop Previous Sound.
PPTX reads and writes p:stSnd loop="1" and p:endSnd, keeps the sound's
file name in p:snd/@name, and setting a slide's transition to None no
longer drops its sound.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:30:19 +03:00
Nikita RakitinandCursor 4ea79d4c5e Shapes: Edit Points — point types, Bézier handles, right-click menu
shape.pointType makes a vertex smooth (handles in line and equal),
straight (in line) or corner (independent), turning its segments into
curves so it has handles. shape.moveHandle drags a control handle; the
other one follows as the point's type says (given, or read from the
handles). shape.points also reports each vertex's type.

Canvas: clicking a vertex shows its handles, which drag (one undo step
per drag) and follow the type chosen for it. Right-click a vertex for
Smooth / Straight / Corner Point and Delete Point, or anywhere for Exit
Edit Points; a shape's context menu gets Edit Points. The selection box
is hidden while points are edited.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:29:57 +03:00
Nikita RakitinandCursor 733522d528 Shapes: Edit Points — drag, add and delete a shape's vertices
New cmd::points with shape.editPoints (enter/leave; a preset becomes a
freeform in shape-local points), shape.points (vertices in slide points),
shape.movePoint, shape.addPoint (splits a line, quadratic or cubic
segment) and shape.deletePoint. After each change the box is fitted
around the outline and kept in place on the slide, rotation and flips
included. Closing edges are made explicit so they take points too.

On the canvas the edited shape shows its vertices as handles: drag one
(one undo step per drag), Cmd/Ctrl-click a vertex to delete it or the
outline to add one; Escape, Enter or a click elsewhere leaves Edit
Points. Shape Format ▸ Edit Shape gets an Edit Points item.

Not yet: smooth/straight/corner point types and Bézier control handles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:22:57 +03:00
Nikita RakitinandCursor 5eef9fb55a Pictures: compress pictures to a target resolution
Picture Format › Adjust gains Compress Pictures, with PowerPoint's
options: HD 330, Print 220, Web 150 or E-mail 96 ppi, apply to the
selected pictures or all, and delete cropped areas. The new
picture.compress command downsamples each PNG, JPEG, BMP, TIFF or WebP
picture to the pixels its shown size needs at that resolution (JPEG
stays JPEG at quality 85, the rest become PNG), cuts away cropped areas
when asked, and only replaces a picture when the result is smaller or a
crop was removed. Originals nothing else uses are dropped so the saved
file shrinks; one shared with a picture that wasn't compressed stays.
One undo step restores everything.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:21:57 +03:00
Nikita RakitinandCursor 37bd53c62e Home: Replace Fonts dialog and font.replace command
font.replace {from, to} renames a font family everywhere it is named
explicitly: runs (latin, east Asian, complex script and symbol faces), end
marks, bullet fonts, list styles of shapes, layouts and masters, the master
text styles, the deck default style, notes, tables and the theme fonts, so
placeholders that follow the theme change too. Names compare
case-insensitively, theme references (+mn-lt) are left alone, a replace that
finds nothing adds no undo step, and missing fonts are recomputed.
font.used lists the families the slides draw in, for the dialog's Replace
list.

Home gets a Replace Fonts… button opening the dialog: Replace (fonts in the
deck), With (type or pick from installed fonts), and it stays open with a
confirmation like PowerPoint's. Ribbon small buttons now expose their label
to accessibility, which the UI test uses to click it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:20:09 +03:00
Nikita RakitinandCursor 7432c61665 Slides: reuse slides from another presentation
Home › New Slide gains Reuse Slides… and Reuse Slides (Keep Source
Formatting)…, backed by a new slide.reuse command that inserts the
slides of a .pptx or .deckcraft file (all, a list, or a range) after the
current slide in one undo step. By default each slide moves to the
destination layout with the same name or kind, so it takes this deck's
theme; keepFormatting brings the source master and its layouts along.
Pictures, media and sounds are copied under fresh media ids, so ids
that clash between the two decks no longer mix up pictures, and shape
ids are renumbered with animations, triggers and connector ends
following them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:14:46 +03:00
Nikita RakitinandCursor 36ec1dc0ca Format Shape: 3-D Format and 3-D Rotation controls
shape.effects gains `threeD`: top and bottom bevel (preset, width,
height), depth, contour, material, lighting, camera preset, X/Y/Z
rotation, distance from ground, and reset. The settings are read from and
written back to the shape's kept a:scene3d / a:sp3d XML (new
cmd::three_d), so they render through the existing camera and bevel code
and survive a PPTX save; extrusion and contour colours are kept as is.

The `bevel` key used to set only Effects::bevel, which neither the
renderer nor the PPTX writer reads, so a bevel set from the app vanished
on save; it now goes through the same path.

The Effects page of the Format Shape pane gets 3-D Format and 3-D
Rotation sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:13:36 +03:00
Nikita RakitinandCursor e9315bc141 UI: seeded whole-window soak with memory and timing bounds
ui_soak_tests.rs drives the app through the egui test harness with seeded
random clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the
sample deck. Every ten actions it checks that every document validates, the
selection is in range, undo, journal, thumbnail and texture caches stay
bounded, and no command or render panic was caught; at the end, that input
really reached the editor and the mean frame stays under 250 ms. Two seeds x
150 actions run in CI; DECKCRAFT_UI_SOAK_STEPS/SEED run it long.

Panics swallowed by the guards were invisible to tests: Session now counts
them (panics_caught) and deckcraft_render::panics_caught() counts render
panics drawn as blank. Textures::thumb_count exposes the thumbnail cache size.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:09:48 +03:00
Nikita RakitinandCursor ad24f545e2 Format Shape: picture or texture fill options
shape.fill gains pictureProps for a picture fill: tile on/off, tile
offset X/Y (pt), scale X/Y, alignment and mirror type, stretch offsets
(left/top/right/bottom), and rotate with shape. `transparency` now also
sets a picture fill's opacity. Values are clamped and unknown keywords or
non-finite numbers ignored. The Fill page of the Format Shape pane gets a
"Picture or texture fill" option with a picture source button and these
controls. The model, renderer and PPTX reader/writer already carry the
fields; a new engine test checks they survive a PPTX save and reopen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:09:33 +03:00
Nikita RakitinandCursor 460a2712e4 Transitions: choose a transition sound and play it in the show
Transition sounds were read from and written to .pptx but could not be
set and never played. Transitions › Timing gets a Sound box ([No Sound]
or Other Sound…), backed by a new transition.sound command that embeds
an audio file on the selected slides (or removes it with none). In the
slide show the sound plays as its slide comes in and, as in PowerPoint,
keeps playing into the next slide unless that slide has a sound of its
own. Built-in sounds, Stop Previous Sound and Loop Until Next Sound are
still missing; the parity docs say so.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:07:06 +03:00
Nikita RakitinandCursor 283b30f12d UI: WordArt Transform gallery applies a text warp
Shape Format ▸ Text Effects lists the 40 prstTxWarp presets and
No Transform. format.textWarp sets or clears that warp on the
selected text, with optional adjust values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:58:35 +03:00
Nikita RakitinandCursor 607ffc5591 Text: set text transparency from Text Fill (#32)
There was no way to make text see-through: the only Transparency
slider was the shape fill's. The new format.textTransparency command
keeps the text's colour and sets its alpha (replacing any it had, and
dropping it at 0). A run without its own colour takes the colour it
shows now, so inherited placeholder or theme colours stay as they are.
Gradient text gets the alpha on every stop. The Format Shape pane's
Text Options page gains a Text Fill section (colour and Transparency),
and the Shape Format › Text Fill dropdown has the same slider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:55:21 +03:00
Nikita RakitinandCursor 14bc6a7ef9 Format Shape: shadow, glow and reflection detail controls
shape.effects gains shadowProps / glowProps / reflectionProps that adjust
the current effect (or add a default one): shadow colour, transparency,
blur, angle and distance; glow colour, transparency and size; reflection
transparency, size, distance and blur. Values are clamped, non-finite ones
ignored. The Effects page of the Format Shape pane exposes them as sliders
and colour pickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:52:56 +03:00
Nikita RakitinandCursor 85833cf5f5 Engine: seeded editor soak tests; tiny charts no longer blank the slide
soak_tests.rs drives every non-file command with plausible and hostile
parameters over a ~100-slide deck, checking after each step that the
document validates, the selection is in range, the undo stack is bounded,
undo then redo restores the exact document, and every 100 steps that the
deck saves and reopens as .deckcraft and .pptx. Four seeds x 400 steps run
in CI (under a second); DECKCRAFT_SOAK_STEPS/SEED run it long.

The long soak found that a chart a few points wide panicked in f64::clamp
(label gutter min above max), and the render guard then blanked the whole
slide. chart.rs now uses a non-panicking clamp_soft for the gutters and the
zero line; a render test draws tiny charts next to a shape without the
guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:50:23 +03:00
Nikita RakitinandCursor 16c5d6a6a3 Format Shape: line compound, cap, join, arrows and sizes; lock aspect ratio
The Format Shape pane's Line page had only colour, width and dash. It now
has PowerPoint's compound type, cap type, join type, begin/end arrow type
and begin/end arrow size drop-downs, and the Size page has Lock aspect
ratio for the width and height fields. shape.line gains compound,
headSize and tailSize, and changing an arrow's kind keeps its size.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:45:42 +03:00
Nikita RakitinandCursor e6920f8ebe Print: choose paper size and orientation for notes and handouts
The Print page always laid notes pages and handouts out on portrait US
Letter. It now offers Letter, A4, Legal and A3 in portrait or landscape,
as PowerPoint's Print settings do, and the preview shows the chosen
page. file.export, app.print and the MCP export tool take the same
`paper` and `orientation` params; unknown values are an error. A notes
page keeps its slide within the top half of the page, so landscape
paper still leaves room for the notes. Full-page slides still print on
slide-shaped pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:43:54 +03:00
Nikita RakitinandCursor 1b9b830f32 Animation Pane: advanced timeline with draggable bars
The pane's bars now come from deckcraft_anim::Timeline, the same schedule the
slide show plays, so With/After Previous, delays, repeats and auto-reverse line
up on a shared seconds ruler. Dragging a bar sets the delay and dragging its
right edge sets the duration (snapped to 0.05 s), each through
animation.timing, so one undoable command per drag. Bar geometry and drag maths
live in animtimeline.rs as pure, unit-tested functions; kittest drives the
drags. Parity and gaps docs updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:30:05 +03:00
Nikita RakitinandCursor 2a79cb7671 Shapes: smooth a rectangle's corners into a squircle (#286)
deckcraft_geom::squircle builds a rectangle outline whose corners start
curving earlier along the edge and keep a shorter circular arc, as design
tools' corner smoothing does. shape.smoothCorners turns rectangles and
rounded rectangles into that outline (custom geometry, so it round-trips
through PPTX), and Edit Shape gains a Smooth Corners slider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:17:39 +03:00
Nikita RakitinandCursor eec67560df Fonts: find fonts where Linux and Flatpak keep them; add and rescan commands (#277)
The font menu lists every family the scan of the system font folders finds,
but on Linux the scan only looked in /usr/share/fonts, /usr/local/share/fonts,
~/.fonts and ~/.local/share/fonts. Fonts under XDG data dirs, in folders
fontconfig is configured with (NixOS, Guix, custom setups) and, inside
Flatpak, the host's fonts never showed.

- system_font_dirs adds $XDG_DATA_HOME/fonts and each $XDG_DATA_DIRS/fonts,
  Flatpak's /run/host/{fonts,local-fonts,user-fonts}, and the <dir> entries
  of /etc/fonts/fonts.conf, local.conf and conf.d; duplicates are dropped.
- font.add {data} adds a font from its bytes for the session; font.rescan
  catalogs the installed fonts again, and the font menu has a
  "Rescan Installed Fonts" item for fonts installed while DeckCraft runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:16:23 +03:00
Nikita RakitinandCursor 07dd513c54 Show: render the next slide in the background while the current one rests (#240)
Advancing rendered the new slide on the UI thread at full screen size
before anything changed on screen. While a slide rests (no animation or
transition playing) the show now renders the next slide's first frame on
a background thread; entering that slide uploads the ready image instead
of rendering, waiting for the render if it is still running. Slides with
animated GIFs and the web build keep rendering on entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:11:38 +03:00
Nikita RakitinandCursor ede2ca69b6 Designer: layout ideas that rearrange the current slide (#314)
Design Ideas showed the current slide once per built-in theme, and picking
one ran design.theme on the whole presentation; with a large picture every
idea looked the same. The pane also re-rendered eight slides every frame.

- New commands design.ideas (query) and design.applyIdea {id, slide?}
  (cmd/ideas.rs). A slide with a picture gets full-bleed with the title on a
  band, picture left or right with the text beside it, and a framed picture;
  a text slide gets a title column, a centred title and a title at the foot.
  An idea moves the slide's own shapes, crops the picture to cover its new
  box, sends it to the back, and changes only that slide (one undo step).
- The pane shows the layout ideas first, then the themes under a "whole
  presentation" heading, and caches the thumbnails until the deck revision,
  slide or pane width changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:10:59 +03:00
Nikita RakitinandCursor c7a4930c4c UI: open presentations in the background with a progress window (#350)
Opening from the file picker or the Backstage recent list read and parsed
the file on the UI thread, so a large PPTX froze the window. The read now
runs on a thread (engine: read_presentation_file / add_presentation_file),
and a modal shows the file name, elapsed time and Cancel; Escape cancels
too. A cancelled read is dropped when it finishes. Scripted opens (control,
MCP, CLI) and the web build stay synchronous.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:08:47 +03:00
Nikita RakitinandCursor 7d23e4670b Charts: pie and doughnut data labels use the number format (#324)
Pie and doughnut data labels still went through fmt_num, which rounded
anything non-integral to one decimal (999.99 drew as "1000.0"). They now
use Chart::data_label like the other label paths (series format, else
axis format, else General), and fmt_num is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:01:43 +03:00
Nikita RakitinandCursor 6b071aa053 Video: check the whole picture store against a memory budget (#398)
Per-axis size caps let AV1 accept 65536x65536 frames (over 8 GiB of luma)
and did not count references or pictures in flight. A shared budget module
in deckcraft-bitstream sizes a picture with checked arithmetic, and the
AV1, VP9, HEVC and H.264 decoders refuse a picture size whose current
picture, reference slots and in-flight pictures would exceed 4 GiB (1 GiB
on 32-bit), before allocating. set_memory_budget lowers it for hosts and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:58:31 +03:00
Nikita RakitinandCursor 5d4bfc0846 Media: keep playback with the document and the clip
Shape ids are local to one presentation, and decoded audio was cached by the
address of a buffer the cache did not keep. Switching decks, closing the last
one, or replacing a clip could keep the previous sound. Playback now stops
when the document changes or closes, a new source replaces the voice's audio,
and the decode and duration caches hold the bytes they are keyed by.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:57:08 +03:00
Nikita RakitinandCursor 241ecd59d7 Recovery: unique uids, per-instance ownership, keep snapshots until replaced (#390)
Document uids started at 1 in every process, so two instances sharing
the recovery folder overwrote and deleted each other's entries, and
file.recovery.open deleted each entry as soon as it was loaded, leaving
nothing if the app died before the next checkpoint.

- Uids are a random per-process base plus a counter (below 2^53).
- Each instance holds a lock on owner-<id>.lock in the folder; entries
  record their owner, and recovery open skips a running owner's entries
  and removes stale lock files.
- Opening an entry no longer deletes it: the recovered presentation keeps
  the entry's uid and takes ownership, so the snapshot stays until the
  next recovery save replaces it or a save/close removes it.
- Entry files and metadata are both written via temp file + rename.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:56:21 +03:00
Nikita RakitinandCursor 1fe638bcf8 AV1: reject duplicate film-grain scaling points; a failed job still publishes (#394)
The film-grain header accepted scaling points whose values didn't strictly
increase, and building the scaling lookup then divided by zero. In threaded
decoding the panic left the frame's reference and picture slots unset, so
the output and dependent frames waited forever.

- The header rejects Y/Cb/Cr point values that don't strictly increase
  (spec 6.8.20).
- The scaling lookup moves into scaling_lut, which skips a non-increasing
  pair and uses checked indexing (defence in depth).
- run_job decodes inside guarded (catch_unwind), so a panic becomes an error
  published to the picture and reference slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:55:12 +03:00
Nikita RakitinandCursor d2066a1f07 Pptx: budget chart points, chart XML size, inflate work and VML lookups (#401)
- Charts: every category/value/size cache draws from one chart-wide
  budget of 1M decoded slots, so sparse ptCount/idx declarations across
  many series can't expand far beyond the XML.
- Chart parts go through the same 96 MiB XML size guard as other XML.
- Package::open charges every inflated byte, including parts it then
  discards, against a 4 GiB decompression budget and stops reading
  entries once it is spent.
- Legacy OLE previews index each VML drawing once (shape id -> picture
  part) instead of reparsing it and its rels for every object.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:53:49 +03:00
Nikita RakitinandCursor 8bab5c2053 Web: keep a picked file on the document and slide that asked for it
The browser file picker only queued a name and bytes. When the read
finished, DeckCraft guessed the command and ran it on whichever
document and slide were active, so a picture, sound or outline could
land in the wrong place, and a later pick could finish first.

The request now keeps the document, the slide or master, the operation
and a cancellation id. A completion applies there, or is dropped when
that target is gone or the picker was dismissed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:53:10 +03:00
Nikita RakitinandCursor 201ab63c48 Render: cap RGBA conversion and bound the failed-image cache (#402)
decode_at accepted up to 400 million pixels and then called to_rgba8, which
the decoder's allocation limit doesn't cover (a grey image converts to four
times its decoded size), and every picture that failed to decode was kept,
bytes and all, for the life of the process.

- Raster decodes check the RGBA size (512 MiB cap) from the header before
  decoding and again before the conversion.
- The failed-source cache keeps at most 256 entries and 64 MiB of encoded
  bytes, oldest forgotten first; a larger failed input isn't kept, and a hit
  must be the same Arc.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:52:20 +03:00
Nikita RakitinandCursor cb26e7a04f Text: bidi levels for language runs in linear time (#403)
Currency and percent signs in right-to-left runs looked ahead through the
whole ET run for every sign, and each neutral span searched both ways for
the nearest strong direction. Both are now precomputed or carried along, so
a long run of signs or many short neutral spans cost linear work. The old
code stays in the tests as a reference and 4000 random paragraphs match it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:52:15 +03:00
Nikita RakitinandCursor ce878ef74e Render: budget shape path-gradient work on dense outlines (#404)
A shape path gradient tested every raster pixel against every flattened
outline edge, so an outline with tens of thousands of segments cost
billions of tests and stalled rendering. The outline is now simplified
before rasterising so pixels x edges stays under 64M: points closer than
a raster pixel are dropped first, then every k-th point is kept, with
each polygon left closed and at least 8 edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:49:02 +03:00
Nikita RakitinandCursor 272ab5f96f Edit: copies remap media, connector ends, animation targets and triggers (#406)
Paste added clashing media under a fresh id but left the pasted shapes on the
old id, reid did not touch connector ends despite its comment, pasted slides
kept animations on the original shape ids, and slide.duplicate remapped
animation targets but not triggers.

- reid returns the old-to-new shape map; connector ends on a copied shape
  follow the copy, ends on a shape outside the copied set are detached.
- remap_animations points targets and triggers at the copies (an animation
  whose target wasn't copied is dropped, an uncopied trigger cleared); used
  by slide paste and slide.duplicate.
- bring_media returns the media ids it had to change and paste rewrites the
  shapes (and a pasted slide's picture background) to use them; slide copy
  now also carries a picture background's media.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:47:27 +03:00
Nikita RakitinandCursor fb2a83edb6 Table: inserting and deleting rows and columns keeps merges consistent (#408)
Row and column insert/delete ignored merged cells, leaving covered cells
with no anchor or new cells overlapping an existing span. These edits now
read the merged areas first, shift or resize each one (a row or column
inserted inside a merge widens it, one deleted from it narrows it), move
the anchor and its text to the next covered cell when the anchor row or
column is deleted, and rebuild spans and merge flags from the result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:44:37 +03:00
Nikita RakitinandCursor de098cfa52 Anim: group animations by sequence in one pass (#410)
Timeline::with found trigger sequences with Vec::contains and then rescanned
every animation once per sequence, and checked MAX_EFFECTS only after a
match, so a slide with many distinct triggers cost quadratic work before the
effect cap could stop it.

Animations are now grouped once (an index by sequence, first-appearance order
kept, main first), the build stops at MAX_EFFECTS before the next sequence,
and paragraph groups are deduplicated with a set instead of Vec::contains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:39:57 +03:00
Nikita RakitinandCursor 8dc068e0ba Control server: bound connections, line length, queue and socket timeouts (#418)
The loopback control server spawned a thread per connection, read
unbounded lines, queued requests without limit and set no socket
deadlines. It now caps simultaneous clients (16) and request lines
(16 MiB), uses a bounded request queue (64) that answers busy when full,
closes sockets idle for 10 minutes or blocked on a write for 30 seconds,
and frees a connection slot whenever a client thread ends.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:39:36 +03:00
Nikita RakitinandCursor 952ebad3cf Media: a failed or stalled audio output hands the clock back to wall time (#417)
A cpal stream error was only kept and logged on stop, and the player kept
`streaming` true, so a device that died mid-play froze the voice and video.

AudioOut gains `failure()` (CpalOut reports DeviceNotAvailable), and
Player::tick also treats a stream with no callbacks for a second while a
voice plays as stalled. Either way the stream is stopped, voices continue on
the wall clock, and the next play retries the device; after three failures
the player gives up on the device and plays silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:27:21 +03:00
Nikita 6873a25635 Codecs: a hostile number is a format error, not an overflow
H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic.
2026-10-13 20:16:51 +03:00
Nikita 1be57fee4b H.264: bound frame_num gap work and reject gaps the SPS forbids (#427) 2026-10-13 19:59:48 +03:00
ZetenypappandClaude Opus 5.5 1baf1f5569 Copy and Cut buttons and menus put the text on the system clipboard (#431)
Ctrl+C and Ctrl+X ran edit.copy / edit.cut and handed the returned text
to egui's clipboard; the ribbon's Copy and Cut, the slide's right-click
menu, the menus, the palette and the control channel ran the same
commands through SlideApp::run, which dropped the text, so it never left
DeckCraft. SlideApp::run now keeps the text of a successful Copy or Cut
and hands it to the clipboard on the next frame, like pending URLs.

Regression test: copy_and_cut_commands_reach_the_system_clipboard runs
both commands and checks the frame's CopyText output (it fails without
the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:45:38 +02:00
Sazid Al Bayazid 8ba97e61be Slide show: Honeycomb's hexagons cover the whole slide again (#420)
The row count came from 1/(1.5*rh) - the row spacing alone - and
ignored how far a settled hexagon actually reaches. On a 16:9 slide
the eleven rows stopped at ~75% of the height: from t~0.9, when every
cell has settled, the bottom quarter was plain black until the t=1
endpoint snapped to the full new slide.

The count now derives from the real geometry: rows sit 0.866*rh apart
and the flat bottom zone of a settled hexagon reaches 0.289*rh past
its centre, so counting to the flat-zone reach covers the slide for
every aspect ratio - counting to the vertex reach still left small
V-notches of black above the bottom edge on about a third of them
(US Letter, square, ...). 16:9 gains four rows (15, was 11), 4:3
seven (20, was 13).

Tests: at t=0.99, when every cell is settled and the seams are nearly
closed, a 0.02-step grid over the slide must sit inside settled
incoming cells (padded 1% past the hairline seams) on five aspect
ratios including US Letter and square. Fails on the old count at
(0.2, 0.74); before/after frames rendered with render-transition.
2026-10-13 21:58:25 +06:00
MarekSpodBiedry 007741718e Slide: leading tabs in a new slide's body set the outline level (#370) 2026-10-13 17:52:00 +02:00
MarekSpodBiedry 97855c70a0 File: save through a unique, exclusively created temporary file (#391) 2026-10-13 17:48:28 +02:00
MarekSpodBiedry 09b72e4b70 Pptx: keep the math namespace equations inherit from the slide root or a wrapper (#412) 2026-10-13 17:41:28 +02:00
MarekSpodBiedry 902edb3c85 UI: notes, alt text and rename drafts no longer carry over to another document (#413) 2026-10-13 17:40:31 +02:00
MarekSpodBiedry d4cf50dd3d MCP: do not resend a command whose reply was lost (#409) 2026-10-13 17:38:28 +02:00
MarekSpodBiedry 33ade63687 Table: split and cell fill stay inside the table (#400) 2026-10-13 17:38:20 +02:00
MarekSpodBiedry 45b8dce220 Info: keep a property draft when another field or the side menu is clicked (#422) 2026-10-13 17:38:13 +02:00
MarekSpodBiedry 27d73db0d9 Engine: a command that fails after editing puts the presentation back (#405) 2026-10-13 17:38:10 +02:00
MarekSpodBiedry 863f7cbd87 Engine: moving the selection to another text body settles the composition (#421) 2026-10-13 17:37:44 +02:00
MarekSpodBiedry a4a7ebe658 Bitstream: reject bad NAL length widths, check NAL endpoints and signed Exp-Golomb limits (#397) 2026-10-13 17:37:25 +02:00
MarekSpodBiedry 9aebc8ba51 AV1: reject tile groups that repeat, skip or reorder tiles (#395) 2026-10-13 17:37:24 +02:00
MarekSpodBiedry efb2127e4e Format scientific number formats (0.00E+00) in chart labels (#419) 2026-10-13 17:36:03 +02:00
MarekSpodBiedry c623b4f555 Matroska: stop following chapter and tag nesting past 32 levels (#396) 2026-10-13 17:35:58 +02:00
NikitaandCursor 0b35b74c4d Show: a laser pointer that leaves no ink
Cmd/Ctrl+L and the on-screen button draw the show's laser colour at the pointer. The pen and the laser are not on together, and a click does not advance the slide while the laser is up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:35:32 +03:00
MarekSpodBiedry eff88eaf59 CLI: render-transition refuses --t values that would share one file name (#423) 2026-10-13 17:34:17 +02:00
MarekSpodBiedry 34372569e7 xtask: assets audit no longer panics on Unicode names and reads real paths from git (#424) 2026-10-13 17:34:03 +02:00
MarekSpodBiedry 3ed12de918 Opus: validate the header in Decoder::from_head (#425) 2026-10-13 17:33:29 +02:00
NikitaandCursor 525b7ed3f9 Palette: skip commands that need a value
Searching for Export ran file.export with an empty object and reported a missing path. The palette now offers only commands that accept that call, so Export opens the dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:31:02 +03:00
NikitaandCursor 82dcd86c88 Pptx: read and write embedded fonts
A deck that embeds a TrueType or OpenType face, including one wrapped as Embedded OpenType, now draws with that face and saves it back. MicroType Express subsets stay in the file and still draw in a stand-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:22:48 +03:00
Sazid Al Bayazid e70fce996b Model: an exhausted id space refuses growth instead of duplicating ids (#407)
alloc_id bumped next_id with saturating_add: a presentation whose
next_id reached u32::MAX (a hostile file can arrive that way;
fix_next_id imports it) handed out that same id to every new slide,
shape, layout and media - duplicate ids, ambiguous references, silent
corruption on every later insert.

alloc_id now returns Option<u32> via checked_add, and every caller
refuses: the engine's insert/duplicate/media/layout commands answer
with a clear error naming the command, paste and duplicate re-id
helpers propagate it, media addition is fallible, and a fresh slide is
Option. The pptx export's synthesized blank layout is guarded the same
way. Loading keeps such a deck viewable: repair re-ids duplicate
slides while it can, and a duplicate it cannot re-id stays as it is -
growth is refused, and only that duplicate keeps its ambiguous id.

Renumbering the loaded graph was rejected: it needs the
cross-document reference remapping that issue #406 tracks.

Tests: model exhaustion (alloc_id None, fresh media refused, a new
slide refused), engine (slide.new, shape.insert, slide.duplicate and
edit.duplicate all refuse; the document holds no duplicate ids),
format repair (an unre-id-able duplicate stays visible).
2026-10-13 21:13:25 +06:00
NikitaandCursor 4fee02636a Metafile: clip EMF and WMF drawing to rectangle regions
INTERSECTCLIPRECT, EXCLUDECLIPRECT and EXTSELECTCLIPRGN limit what later records paint, including a region made of several rectangles. A rotated map stores the bounds of the clip rectangle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:57:34 +03:00
NetterSilver 12dd392705 Docs: update the progress docs for this change 2026-10-13 16:41:57 +02:00
NetterSilver 7887a13868 Colour menus: an Eyedropper that takes a colour from the slide (#367)
Every colour menu ends with Eyedropper. Choosing it closes the menu; the pointer is a
crosshair over the slide with the colour under it and its hex beside it, a click takes
the colour (slide.colorAt) and Esc, a right click or a click off the slide cancels.
The colour comes back through the menu that asked, which opens for a frame and returns
it as if its swatch had been clicked, so Shape Fill, Shape Outline, Font Color, the
Format pane and the other callers all apply it their own way without changes.
2026-10-13 16:41:57 +02:00
NetterSilver 35312529e1 slide.colorAt: the colour the slide is drawn with at a point (#367)
A read-only command, {slide?, x, y (slide units), scale?}: the slide is rendered and
the pixel at the point is returned as {color: "RRGGBB", alpha, x, y}. This is what the
Eyedropper takes, and what scripts and MCP use to read a colour off a slide. A point
outside the slide, a missing or non-numeric coordinate or an unknown slide is an error.
2026-10-13 16:41:57 +02:00
Sazid Al Bayazid 78a99ad1cf Start screen: a missing Recent file is marked, its error shows, and entries can be removed (#172)
The start screen reported errors through set_status, but the status bar
is only drawn while a presentation is open: clicking a Recent entry
whose file had moved or been deleted did nothing visible, and the same
went for every other start-screen error, like a failed sample open.

The start screen now draws the transient status message itself: a real
label, so screen readers see it too, wrapped so a long OS error does
not clip at the panel edge. Recent entries whose file no longer exists
are dimmed with a "(missing)" mark before anything is clicked, each
entry has a remove button, and a successful open clears a stale error.

Existence is probed once per path per session; a stat on a dead network
share can block for the OS timeout, so UNC paths and URLs are never
probed and are judged when opened instead.

The remove button is a plain multiplication sign: U+2715 has no glyph
in the UI font stack and egui would draw the replacement character.

Tests: the missing mark and the drawn error (kittest, clicked through
the real UI), removal from the list, and the negative case - an
existing file is not marked.
2026-10-13 19:43:15 +06:00
Sazid Al Bayazid 69d4893689 Comments: threads and resolved state survive a .pptx round trip (#70)
The writer emitted the legacy comment part only: replies were flattened
into sibling comments, the resolved flag was dropped. Modern PowerPoint
decks carry a second, threaded part (p188) as well - and DeckCraft read
both, importing every comment twice.

Export now writes the modern threaded part (p188:cmLst, with the slide's
commentRel anchor ext and the modern authors part) next to the flattened
legacy part, the same pair of parts PowerPoint itself writes: older
readers keep the legacy view, PowerPoint 2021+ and DeckCraft read
threads, replies and resolved state. The reader prefers the modern part
when both are present and scales modern positions as EMU instead of
legacy 1/8 pt, so real PowerPoint decks stop double-importing their
comments and keep their marker positions.

Comment ids are deterministic GUIDs from the existing tables::guid, so
exports stay reproducible without a new dependency.

Known limitation for a follow-up: comments anchor with
p188:unknownAnchor (schema-valid) rather than a slide moniker, which
needs part identity the model does not have yet.

Tests: threads plus resolved state round-trip on top-level comments and
replies, both parts and the anchor-rel wiring asserted in the raw
package, part numbering with comment-free slides skipped, legacy-only
and modern-only packages import each with their own position units, and
XML-special characters survive the trip.
2026-10-13 19:25:04 +06:00
Brandon Thomas 457406a7a5 Docs: keep the progress table in one piece 2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 e614f0d6d6 Docs: record the wild shape rendering fixes and partial 3-D rendering
Progress log entry for the wild-case rendering fixes; "Bevel and 3-D
rotation" moves from missing to partial (approximate bevels, cameras and
face lighting; no extrusion, rigs or 3-D pane); gap estimate narrowed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 8ee082f257 Render: 3-D shapes' faces are lit even without a bevel
A shape with scene3d and sp3d but no bevelT was drawn in its flat fill
colour. PowerPoint lights the front face from the light rig whether or
not there is a bevel, so a plain 3-D rotated rectangle is lighter than
its fill. Model the face with the same threePt approximation as the
bevel (no rim), for solid fills.

Wild 3-D slides: s8 6.35 -> 4.26, s9 19.74 -> 17.68. Corpus unchanged
except lo tdf114848 s4 2.13 -> 1.94.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 238157d98e Render: 3-D cameras turn shapes (affine approximation)
A shape seen through a 3-D camera preset (isometric, perspective...) or
turned with a:camera/a:rot drew flat and front-on. PowerPoint turns the
shape's plane in space and projects it. Fitted black-box to its exports
(a picture with coloured corner markers under every preset and several
rotations; every fit within half a point on a 100 x 75 pt shape): points
about the centre are turned Rz(-rev) Rx(lat) Ry(lon), perspective cameras
then dividing by D / (D - z) with D about 5-16 shape sizes; a:rot turns
the other way about the horizontal and vertical axes. DeckCraft now draws
the shape under the affine transform that best fits its turned corners:
exact for orthographic cameras, within 2% of the size for perspective
ones (an affine map can't foreshorten). Oblique cameras leave a flat
shape as it is; depth (extrusion) is not drawn.

Wild synthetic set: camera presets 15.02 -> 6.35, 3-D picture styles
16.00 -> 7.72, extruded cameras 21.41 -> 19.74.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 4e2cbaed79 Geom: moon, magnetic disk and drum by the standard
- The magnetic disk's and drum's end ellipses were half the size of
  PowerPoint's: ECMA-376 makes them h/6 high (disk) and w/6 across
  (drum); measured on PowerPoint's outlines.
- The moon's inner edge was a half ellipse of the box's height; the
  standard's is an arc of a larger ellipse through the right corners and
  (adj * w, middle) whose centre lies right of that point (radius g18w
  across, dy1 down), and so it is fuller. Text rectangle from the standard
  too.

Wild synthetic set: preset galleries 3.76 -> 3.47, 3.45 -> 3.21,
4.31 -> 4.00 and others slightly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 ee71879f80 Render: pictures reflect their pixels
A reflection on a picture (PowerPoint's "reflected" picture styles) drew
nothing: the reflection painted the shape's fill, and a picture has none.
It now mirrors the picture itself, clipped to its outline, under the same
fade.

Wild synthetic set: picture effects slide 16.77 -> 16.00 (the rest is
3-D camera rotation, not modelled).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 7cb62d5197 Pptx/Render: picture fills with rotWithShape=0 stay upright
A shape's picture fill marked not to rotate with the shape turned and
flipped with it (rotWithShape was neither kept nor written: always 1).
PowerPoint keeps such a picture upright, stretched (or tiled) over the
box the rotated shape covers on the slide and clipped to the shape. Now
read, written (PictureFill::upright) and drawn so.

Wild synthetic set: picture fills on rotated/flipped shapes 12.89 ->
3.87.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 c0dcfc1db5 Geom: stars stretched by the standard's hf/vf factors
Five-, six-, seven- and ten-pointed stars used their own normalisation
and came out narrower or shifted against PowerPoint's. ECMA-376 places a
star's points on an ellipse around (hc, vc * vf) with radii half the box
times hf / vf (star5 1.05146 / 1.10557, star6 1.15470, star7 1.02572 /
1.05210, star10 1.05146), so the points touch the box's edges; now done
for all stars (checked exactly against PowerPoint's outlines at three
sizes and inner-radius adjusts).

Wild synthetic set: star galleries 4.08 -> 3.76, 3.77 -> 3.45,
4.62 -> 4.31 and the adjust galleries slightly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 9238a915a2 Render: presets PowerPoint mitres by default get sharp corners; miter limit 8
An outline naming no join was always rounded. PowerPoint rounds most
presets but mitres some by default: classified per preset by comparing
its export (8 pt outline, no join) with round- and miter-joined renders:
the plus, right/up/down block arrows (not the left one), chevrons, stars
from five points up, the equation shapes, curved ribbons and the action
buttons. Miter joins also take PowerPoint's default limit of 8 widths
(kurbo's default was 4), so sharp star points no longer bevel off.

Wild synthetic set: joins slide 0.43 -> 0.21, outline-on-pattern slide
3.95 -> 3.76, preset galleries slightly better.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 2e68e54eec Render: gradients with rotWithShape=0 keep to the slide's axes
A gradient fill marked not to rotate with its shape turned and flipped
with it anyway. PowerPoint lays such a gradient out upright, over the box
the rotated or flipped shape covers on the slide (a 90 degree gradient on
a quarter-turned triangle still runs top to bottom). Linear and radial
gradients now do; rect and outline path gradients still follow the shape.

Wild synthetic set: gradients on rotated/flipped shapes 5.48 -> 1.34,
gradient angles slide 6.75 -> 1.95.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 239e41bdf6 Render: a picture's shadow covers the whole picture
The shadow of a picture is the picture recoloured to the shadow colour,
but drawing the picture left its pixel-space transform set, so the
recolouring rectangle (sized in points) covered only part of a picture
whose bitmap is bigger than it is shown: a 400 px photo at 240 pt cast a
shadow of its top-left corner only. The shape's transform is restored
before recolouring.

Wild synthetic set: framed pictures with shadows 7.67 -> 5.95, rotated
picture with a shadow 10.67 -> 10.07.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 cef6e1a8c8 Render: table borders draw their dashes, compound bands and diagonals
Explicit cell borders were stroked as plain solid lines (no dash, compound
or cap), and diagonal borders (lnTlToBr, lnBlToTr) were read but never
drawn. Both now go through the shape line stroker; diagonals are drawn
under the cell edges.

Wild synthetic set: border styles slide 5.67 -> 2.26, thick merged-cell
borders 1.20 -> 0.69.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 9d4541b8b4 Render: bevelled shapes lit like PowerPoint's default 3-D light (approximation)
A shape with a top bevel seen through a 3-D scene drew flat. Fitted to
PowerPoint's export of the bevel presets on a solid fill with the default
three-point light rig and matte material, DeckCraft now lights the face
and a band as wide as the bevel inside the outline as surfaces: Lambert
light from the upper left (azimuth 240, 45 degrees up), c * (0.453 +
0.731 * n.L) + 14.7 per channel, the band's normal tilting outwards by
the profile's slope (quarter circle by default, straight for angle).
Within about 15 grey levels RMS of PowerPoint on the circle bevel; other
profiles use the circle's, pictures get light and dark veils, and other
light rigs and materials (and anything without a scene) stay flat.

Wild synthetic set: bevel presets slide 5.20 -> 2.48, materials/rigs
13.25 -> 12.73.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 46dfb23dc5 Render: outline (shape) path gradients from an off-shape focus or a focus box
A "shape" path gradient with its focus off the shape (fillToRect on a
corner of an ellipse's box) filled solid with its last colour: the
position came from the nearest crossing of the ray from the focus with
the outline, and rays from outside cross it twice or miss. The contours
are the outline scaled about the focus, which a point first reaches at
the ray's farthest crossing; that is now used. A focus rectangle (rather
than a point) is the first colour throughout, with the colour running from
its edge to the outline, as PowerPoint draws it.

Wild synthetic set: shape path gradients 18.95 -> 3.54.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 675d20417e Pictures: black and white, inverted alpha, and PowerPoint's grey weights
- a:biLevel (Color > Black and White) was ignored. It is now read, written
  and drawn: white where the luminance reaches the threshold, else black
  (observed thresholds at 30/50/70% on colour swatches).
- a:alphaInv was ignored; an opaque picture with it is invisible in
  PowerPoint. Now read, written and drawn.
- Grayscale, duotone and saturation weighed channels 0.299/0.587/0.114;
  PowerPoint's export greys pure red to 54 and green to 182: Rec. 709
  weights (0.2126/0.7152/0.0722).

Wild synthetic set: recolour slide 7.60 -> 1.15, picture corrections
13.36 -> 7.36.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 754841aaa4 Pptx: a table naming no style reads as the table grid style
A table whose tblPr names no tableStyleId drew without any lines.
PowerPoint draws such a table with thin dark grid lines (as its "No Style,
Table Grid"); observed on a synthetic deck with and without the explicit
"No Style, No Grid" GUID. Such tables now read as the grid style.

Wild synthetic set: tables with cell margins/fills/styles 4.80 -> 2.82,
11.28 -> 10.15, 6.86 -> 3.16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 bf877411fb Render: picture brightness with contrast as PowerPoint combines them
Brightness was added after the contrast stretch. Measured on PowerPoint's
export of grey ramps (5 x 5 grid of -80..80% brightness and contrast):
contrast scales about mid-grey (x(1+c) below zero, /(1-c) above) and
brightness adds b * 255 scaled by (1 + cf) / 2, half before the stretch
and half after; +40% brightness with +40% contrast maps black to 54, not
17. DeckCraft now matches the 25 ramps within 5 grey levels.

Wild synthetic set: brightness/contrast grid 15.93 -> 13.36, recolour
slide 8.74 -> 7.60.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 3b04dd14be Render: pictures with opposite corrections no longer share decoded pixels
The decoded-picture cache keyed adjustments with an xor-multiply mix in
which brightness b, contrast c and -b, -c collide (the sign bits cancel),
so of two copies of a picture, one +40% bright and contrast and one -40%,
both showed whichever was decoded first. The key is now a real hash.

Wild synthetic set: brightness/contrast grid 27.86 -> 15.93.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 772abb2154 Render: pattern fills blend neighbouring cells between whole-pixel sizes
Pattern tiles were sampled nearest-cell, exact when a pattern cell
(0.75 pt) is a whole number of output pixels (a 960-wide export of a
720 pt slide) but harsh otherwise: at 960 px for a 960 pt slide (0.75 px
per cell) PowerPoint shows greys where a pixel straddles two cells, and
DeckCraft showed only the two colours, in different places. The tile is
now sampled bilinearly. Fitted against PowerPoint's export of all 54
presets at 0.75 px per cell: mean error 0.067 (bilinear) against 0.082
(nearest) and 0.095 (area average); still not exact (narVert reads
255/128/0 in PowerPoint, 213/128/42 here). Unchanged at whole-pixel sizes.

Wild synthetic set: patterned backgrounds 21.15 -> 11.31 and
18.00 -> 9.75, pattern galleries 22.50 -> 19.44 and 13.96 -> 11.64.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 84e6c12543 Render: a group's outer shadow is cast by its children
An outer shadow set on a group (grpSpPr effectLst) was ignored. PowerPoint
casts it from everything in the group: the children are now drawn once
more under the shadow's offset, scale and skew (anchored on the group's
box), recoloured to the shadow colour and blurred, below the group.

Wild synthetic set: combined-effects slide (shadowed group) 2.00 -> 0.98.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 aafce0d2ef Render: gradient lines draw their gradient, heads included
A line filled with a gradient drew in its first stop's colour only. As
PowerPoint draws it, the gradient is laid out over the box the stroke
covers (the outline's bounds grown by half the line width, before the
line is cut back under its arrowheads), so a horizontal line's 90 degree
gradient runs across its width; arrowheads take the gradient where they
sit.

Wild synthetic set: gradient lines slide 8.43 -> 0.40; picture with a
gradient border 8.54 -> 7.67.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 4773e4ee0f Render: tiled pictures take their size from the resolution they record
A tiled picture fill (including a blipFill with neither stretch nor tile,
which PowerPoint tiles) assumed 96 dpi. PowerPoint sizes a picture by the
resolution its file records: a 32 px tile saved at 90 dpi is 25.6 pt, not
24. The PNG pHYs chunk and JPEG JFIF density are now read (rounded to
whole dpi) and scale the tile.

Corpus lo/tdf128596 (tiled checkmark texture): 8.79 -> 0.52 mean diff
(it had drifted from 7.36 when tiles started snapping to whole pixels).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 4012cea0bf Geom: sun, wave and double wave by the standard's formulas
The sun's rays were thin spikes around a smaller core, and the waves'
curves had the wrong amplitude and shift. They now follow ECMA-376: the
sun's eight triangular rays with bases at the standard's fractions of the
core's margin around a circle of radius 50% - adj (with its handle), and
the waves' cubic edges whose control points sit 10/3 of the amplitude off
the edge, shifted by adj2 (text rectangles too). Checked against
PowerPoint's outlines at three sizes and adjust values (within 0.13 pt).

Wild synthetic set: sun 30 -> 9 per-shape diff; group fill slide (waves)
7.88 -> 5.82; whole set 4.71 -> 4.65.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 2965e69e2f Geom: equation shapes by the standard's formulas
Plus, minus, multiply, divide, equal and not-equal were approximations
(bars spanning the wrong width, the X's arms too long, the not-equal slash
at the wrong angle and length, dots sized off the shorter side). They now
follow ECMA-376's guide formulas: bars span 73.49% of the width; adjusts
pin as the standard pins them; the X's arms follow the box's diagonals
cut at 51.965%; the slash leans at adj2 through the full height. Each was
checked against PowerPoint's outline at three aspect ratios and custom
adjust values (max deviation 0.002 pt). Text rectangles follow the
standard too.

Wild synthetic set: equation galleries 10.20 -> 5.32, 10.06 -> 5.31,
5.45 -> 2.47; whole set 4.84 -> 4.71.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 ee4712f829 Geom: lightning bolt, explosions, heart, cloud and document flowcharts as the standard defines them
These presets were hand-drawn approximations and looked visibly different
from PowerPoint's (the lightning bolt had 7 points instead of 11, the
explosions were different polygons, the cloud a ring of lobes without
creases). They now follow ECMA-376's preset geometry, checked against the
outlines PowerPoint gives for them (black-box: merging each preset with a
far-away square and reading the resulting freeform):
- lightningBolt, irregularSeal1/2: the standard's polygons (21600ths);
- heart: its two cubic curves;
- cloud (and cloudCallout's outline): the standard's eleven arcs drawn in
  the path's own 43200-unit square, plus the crease arcs, stretched to the
  box; arcs take DrawingML's angle convention (direction from the
  centre), new Pb::arc_spec;
- flowChartDocument, flowChartMultidocument (three pages, outlined where
  they show), flowChartPunchedTape (half-ellipse edges).

Wild synthetic set (per-shape mean diff, 1:1 gallery): lightningBolt
64.1 -> 2.8, irregularSeal1 41.1 -> 5.3, irregularSeal2 41.2 -> 4.8, cloud
28.2 -> 4.0, multidocument 30.9 -> 7.0, punched tape 27.7 -> 4.1, heart
20.9 -> 3.9, document 15.3 -> 3.8. Whole set 5.05 -> 4.84.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 c2b622a797 Render: tiled picture fills flip, align and size like PowerPoint's
- flip x/y/xy (every other tile mirrored) was ignored: tiles now repeat
  reflected across the flip axes.
- algn was ignored: the first tile now sits on the corner, edge middle or
  centre of the box it names, before the tx/ty offset.
- The tile size came from the decoded (possibly downsampled) bitmap; it is
  now the picture's own pixel size (96 dpi) times sx/sy, rounded to whole
  output pixels as PowerPoint draws it.
- A scale of zero or less drew tiles at 100%; PowerPoint draws nothing.
- Tiles are one repeating image paint instead of one draw per tile (no
  200-tile cap, constant cost).

Wild synthetic set: tiled background flipped/aligned 82.92 -> 1.80, tile
variants 51.15 -> 8.82.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 0cbcbebf68 Render: a 3-D picture's border stays centred on its edge
Follow-up to drawing picture borders outside the picture: PowerPoint does
that for flat pictures only. A picture seen through a 3-D scene with a
bevel (a common picture style) gets its border centred on the edge like
any shape: observed with an 18 pt border, which then covers 9 pt of the
picture. The corpus deck 60810 (white-framed bevelled screenshots) showed
the screenshots' own edges once the border moved out; it now matches
PowerPoint again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 0c5ddb5dea Gradients: read, write and draw a:tileRect
Gradient tile rectangles were dropped on import. PowerPoint's "from
corner" radial presets depend on them: fillToRect puts the focus on a
corner and tileRect stretches the gradient's box a whole size past it, so
the circle is centred on that corner and reaches the far one. Without it
(after the two-point radial change) such fills washed out. A linear
gradient now spans its tile rectangle; a radial one ends on the circle
around it, with the focus still placed in the shape's own box (fitted to
PowerPoint's export of the corpus deck tdf114848: mean diff over the
filled box 0.77). Round-trips through pptx.

Wild synthetic set: tile-rect gradients 16.74 -> 2.48.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 9a7e7e133e Geom/Render: brackets and braces fill; arrowheads follow open paths, not brackets
- Left/right brackets and braces drew only their curve: a filled bracket
  showed no fill. PowerPoint fills them out to the straight side they open
  to (the fill path is the curve closed), outlining only the curve.
- Arrowheads were drawn on any shape with nothing filled (brackets among
  them) but not on open freeforms that had a fill. PowerPoint puts them on
  lines, arcs and open freeforms whether filled or not, never on brackets.

Wild synthetic set: preset galleries 9.44 -> 4.65, 9.55 -> 4.94,
9.60 -> 5.38; extreme-adjust galleries 10.7 -> 6.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 47dfaaca61 Render: shapes filled with their group's fill (a:grpFill) show it
A shape inside a group whose fill is grpFill drew nothing (the fill was
read but the renderer skipped it). PowerPoint fills it with the group's
fill laid out over the whole group: a gradient or pattern runs on across
the children rather than restarting in each. Groups without a fill of
their own pass their parent's on.

Wild synthetic set: group fills slide 27.33 -> 7.88.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 7d642e12c9 Render: a picture's border lies outside the picture
DeckCraft centred a picture's outline on its edge, covering half the
border's width of the picture. PowerPoint draws picture borders wholly
outside: an 18 pt white photo frame leaves the whole photo showing and
reaches 18 pt out (measured on its export; a 2 pt border on a cropped
picture covers 18-20 pt for a picture starting at 20). Pictures now stroke
their border twice as wide, clipped to outside the outline, keeping dash
lengths; the border's shadow is cast from there too. Compound borders
stay centred.

Wild synthetic set: picture frames 13.12 -> 8.54, cropped/flipped pictures
with borders 5.79 -> 2.57, crop-to-shape 8.03 -> 5.25.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 862e2175a7 Render: shadows are cast by what is drawn: no-fill shapes, see-through fills, lines
An outer shadow was always the shape's filled silhouette at the shadow's
opacity: an outline-only shape cast a solid block, a 40% fill cast a full
shadow that darkened it from behind, and lines (and outlines) cast none.
PowerPoint casts the shadow from the drawn shape: the filled part as
see-through as its fill (nothing for no fill) plus the outline, which is
now stroked into the shadow layer with the line's width, dash and opacity.

Wild synthetic set: combined effects slide (no-fill frame, 40% fill,
shadows) 21.88 -> 2.34.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 b3a980dcb5 Render: perspective shadows scale and skew about their alignment point
A shadow's sx/sy/kx/ky were applied about the shape's centre with the
skew unscaled, so a perspective shadow (algn=b, sy 23%, kx -20 deg) floated
in the middle of the shape instead of lying along its bottom edge. As
PowerPoint draws it, the shadow is skewed then scaled about the point of
the box its algn names (b: middle of the bottom edge, tl: top-left...).

Wild synthetic set: shadow alignment/scale/skew slide 5.57 -> 0.41,
picture frames 13.71 -> 12.71.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 0796325a1f Render: radial (circle path) gradients end on the circle around the whole box
An off-centre radial gradient ("from corner", or any fillToRect focus
other than the middle) spread from its focus out to the farthest corner,
so the near side stayed light. Fitted to PowerPoint's export: the first
stop is a point at the focus and the last is the circle through the box's
corners (centre of the box, half its diagonal), with the circles between
moving from one to the other (a two-point radial). A corner focus thus
reaches its last colour at its own corner too. Centred gradients are
unchanged.

Wild synthetic set: off-centre radial background 20.66 -> 1.22, radial
focus grid 6.34 -> 5.69.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 90a9933d46 Render: arrowheads as PowerPoint draws them: the line stops inside, one shape, 2 pt minimum
- A wide line poked its corners (and square caps) out past a triangle,
  stealth or open arrowhead: the line now stops under the head (at a
  triangle's base, inside a stealth notch, where an open arrow's legs meet).
- A see-through line darkened where it overlapped its heads; PowerPoint
  draws line and heads as one shape, so they are drawn opaque into a
  faded layer.
- Heads on lines thinner than 2 pt were too small: PowerPoint sizes them
  as for a 2 pt line at least (measured 6 pt for a medium head on 0.25 to
  2 pt lines).
- An open arrow's point sat a miter past the line's end, with flat-ended
  legs; PowerPoint puts the point's outside on the end and rounds the legs.

Wild synthetic set: open arrows by width 6.85 -> 3.78, 8 pt open arrows
3.84 -> 1.89, triangles by width 3.62 -> 2.17, stealth by width 3.45 -> 2.38.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 2fca333cac Render: dashes with round caps keep their length; square caps only at the line's ends
A round-capped dashed line (PowerPoint's "Round Dot" is sysDot with round
caps) drew as a solid line: each dash grew by its caps and closed the
one-width gaps. PowerPoint keeps the caps inside each dash, so dots stay
separate circles. Square-capped dashes end flat in PowerPoint; only the
line's own start (and its end, when it ends in a dash) is squared off.

Wild synthetic set: 12 pt round-cap dashes 12.69 -> 7.05, 12 pt square-cap
dashes 11.76 -> 6.12, custom dashes 2.12 -> 0.10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon ThomasandClaude Opus 5.5 39adfcd62a Render: custom geometry sub-paths fill even-odd, so inner sub-paths cut holes
A freeform whose path holds a sub-path inside another (an icon, outlined
text, a frame drawn as two rectangles) showed the inner one filled when
both wound the same way: DeckCraft filled custom paths non-zero.
PowerPoint fills every custom path alternately (even-odd), so the inner
sub-path is a hole whichever way it winds.

Wild synthetic set: holes slide 7.60 -> 2.84 mean diff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 08:16:41 -04:00
Brandon Thomas 525ec7b1a9 Docs: keep the progress table in one piece 2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 5ca1dad281 Docs: progress log for the wild text cases
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 6912b5df76 Fonts: a letter with combining marks the run's font lacks takes a face that has them all
"Z̴̡̛͓a̸̧̘l̵̩g̶̞o̸̤" in an Aptos run drew its marks as missing-glyph boxes: no face in the chain
covered the whole cluster and the fallback for its base letter neither, so the run's own font was
kept. PowerPoint draws the marks stacked. A loaded plain face that covers the whole cluster is
now taken before falling back to the run's font. w09-scripts s1 8.40 -> 7.86.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 f5fef50364 Text: rtlCol puts the first column on the right; just/dist anchoring at the bottom; wordArtVertRtl
Observed on PowerPoint's export (w14-xml s12):
- a two-column body with rtlCol="1" starts its first column on the right; DeckCraft read and
  saved the attribute but laid columns out left to right;
- anchor="just" and anchor="dist" place the text at the bottom of the box, like anchor="b";
  DeckCraft drew them at the top;
- wordArtVertRtl stacks characters like wordArtVert but runs its lines from right to left; it was
  read as wordArtVert (and saved back as it). It is now its own TextDir, kept on save.

w14-xml s12 9.47 -> 5.39.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 bab3854484 Text: strikethrough at the font's strikeout position and thickness
The strikethrough was drawn 0.3 em above the baseline, 0.05 em thick, for every font. PowerPoint
uses the font's own strikeout metrics (OS/2): Arial 96 pt strikes rows 347-351, 24.8 px above the
baseline (530/2048 em) and 4.8 px thick; DeckCraft drew 343-347. Faces now carry their strikeout
metrics (0.26/0.05 em when a font has none) and the line matches PowerPoint's rows exactly.
w14-xml s11 4.06 -> 3.24.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 3d22ca642d Fonts: only steer a symbol away from the colour font when that is the face it would get
The previous change sent every text-presentation pictograph to a symbol font, which also moved
bullets such as "▪" (Wingdings substitutes) from the face they had to a wider one: necc-
presentation-0 s13 18.78 -> 19.72. Now a pictograph is redirected to a plain face only when its
fallback would be the colour emoji font; keycaps and U+FE0E sequences still take a plain face.
necc s12-s14 back to 26.38 / 18.78 / 20.91; w14-xml s7 (pictographs) 9.79 -> 7.49 overall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 30b2daadb6 Text: character spacing leaves kerning on again (only Arial drops it in PowerPoint)
A wider probe (Arial, Aptos, Calibri, Segoe UI, Times New Roman, Verdana at 40 pt, each box with
one paragraph spaced 2 pt) shows the earlier rule holds for Arial alone: Calibri, Segoe UI and
Times New Roman keep "AVATAR" kerned beside and inside spaced runs (121 vs 131 px for Calibri).
The rule made spaced Aptos and Calibri text too wide (w15-mixed s9 5.46 -> 7.86), so it is
removed; Arial's behaviour stays a known difference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 85a7f0ce3e Text: a fallback face (emoji, symbols, a missing script) no longer moves the line's baseline
Line height and baseline were the largest over every face on the line, fallback faces included,
so a line with an emoji, a math symbol or Thai in an Aptos run sat 1-5 px lower than PowerPoint's
and pushed the lines below it down. PowerPoint keeps the run's own font metrics (measured: the
second line of six boxes starts at the same height whatever the first line holds). Faces that are
the run's Latin, East Asian, complex-script or symbol font still count.

w14-xml s8 (baselines) 5.54 -> 3.21, w09-scripts s1 10.27 -> 8.39, s2 9.37 -> 7.46,
w15-mixed s8 13.65 -> 8.77; w15-mixed s2 (Arabic) 6.57 -> 7.05.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 232501a9f3 Fonts: emoji presentation as PowerPoint draws it; keycaps and text pictographs in a symbol font
Observed on PowerPoint's export of pictographs in Aptos, Arial and Segoe UI:
- characters that default to emoji presentation draw in colour without a selector: ⭐ ✅ ☔ ⌚ ⏩
  and the other BMP Emoji_Presentation characters (DeckCraft only knew the supplementary-plane
  ones and drew ⭐ ✅ as text);
- text-presentation pictographs (⚠ ♥ ✔ ☎ ⬆ ➡ ▶ ⤴) draw as text even where the colour font is the
  first installed font that has them: they now come from a plain symbol font, symbol families
  first, never the colour one; and a symbol after an emoji no longer inherits its colour face;
- keycap sequences ("1️⃣", "#⃣") draw as text, an outlined key, not the colour keycap.

w14-xml s7 (pictographs) 9.79 -> 6.16, w09-scripts s1 (emoji) 11.56 -> 10.27.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 b8a665f0ca Text: mongolianVert and wordArtVert (stacked) text as PowerPoint draws them
mongolianVert was read as vert (and saved back as vert). PowerPoint turns it like vert but runs
its lines from left to right, the anchor mirrored with them (bottom-anchored text sits on the
right). It is now its own TextDir, kept on save; its lines' places are mirrored across the box.

wordArtVert (Stacked) was turned like vert. PowerPoint stands every character upright, one
under the other (Latin letters about one em apart, spaces their own width), with lines running
left to right. Stacked text now stands each character upright in an em of its own and mirrors
the line order.

Also: a bullet in the second or later column of a multi-column body is drawn on its line's
baseline (it kept the first column's).

w01-vertical s4 (mongolianVert) 11.54 -> 9.38, s5 (wordArtVert) 13.84 -> 13.56; w14-xml s13
6.78 -> 6.60.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 41d839d931 Text: East Asian vertical text (eaVert) stands ideographs and kana upright
eaVert text was laid out exactly like vert: the whole text turned a quarter turn, so Japanese and
Chinese lay on their side. PowerPoint draws ideographs, kana, Hangul, full-width forms, CJK
punctuation, circled numbers and emoji upright, each turned back about the centre of its em box,
while Latin letters and digits stay on their side ("英字123も": 123 sideways). It keeps the glyphs'
horizontal forms (no vert alternates: "、" "「" are simply turned), which DeckCraft now matches.

Glyph runs carry an `upright` flag; the renderer turns each such glyph about its em box centre
(0.38 em above the baseline). w01-vertical s3 (eaVert) 9.70 -> 8.98, s6/s7 (eaVert rotated,
flipped, bulleted) 1.58 -> 1.45, 2.30 -> 1.76.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 bc2fc816d4 Text: East Asian, Thai, Hindi, Arabic and Hebrew auto-number schemes
Every scheme other than the Latin, Roman and circled ones drew Arabic digits ("4." for
ea1ChsPeriod, "- 10 -" for arabic2Minus). Each is now written as PowerPoint's export draws it
(probe deck: one box per a:buAutoNum type and start value): Chinese numerals for ea1Chs/ea1Cht
(十, 十一, 二十一), digit-by-digit kanji with 〇 for ea1Jpn* (一〇), full-width digits for
arabicDb*, a full-width full stop after the East Asian ones, Thai letters (without ฃ ฅ ฆ) and
digits, Devanagari vowels, consonants and digits, the Arabic alphabet (arabic1Minus) and abjad
(arabic2Minus) and the Hebrew alphabet with a hyphen, and the Wingdings-style circles that go to
ten (circleNumWdWhite 13 → ③, circleNumWdBlack → ❶…). w04-lists s2-s4 drew wrong labels in 24 of
36 boxes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 257e79de27 Text: Japanese line breaking keeps small kana and "ー" off line starts; only commas and stops hang
Observed on PowerPoint's export of a wrapped Japanese paragraph (24 pt, 11 characters a line):
it breaks "けません(禁則処" / "理)。ー長音もで" / "す。っゃゅょ小書き文字", i.e. small kana
(っゃゅょ…), the prolonged sound mark and iteration marks never start a line (strict kinsoku),
where UAX #14's default allows it. And "し、句読点「、」や" / "「。」は…": with hanging punctuation on,
a closing bracket that overflows does not hang; only the ideographic comma and full stop (and
their full-width and half-width forms) do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 d30e4edb45 Text: soft hyphens show a hyphen where a line breaks; no break after zero-width space or before em dash
Observed on PowerPoint's export of long joined words:
- a line broken at a soft hyphen (U+00AD) ends with a visible hyphen ("Softhyphenatedwordsina-");
  DeckCraft broke there but drew nothing. The hyphen now takes its room on the line;
- PowerPoint does not break after a zero-width space (it splits the word where it overflows
  instead), where UAX #14 allows a break;
- an em dash ends a line but never starts one ("Em—dash—joined—" / "words—that—are—long—"),
  where UAX #14 allows a break before it too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 fa3db9a777 Text: condensed character spacing keeps pairs kerned
Follow-up to the kerning rule: on the real corpus (ms6: titles condensed by 1 pt from the
master's title style) PowerPoint kerns condensed text, and a closer probe agrees for -0.6, -1,
-1.5, -2 and -3 pt (only -1.2 and -2.5 pt came out unkerned, which no simple rule explains). Only
spacing out by 0.6 pt or more now switches kerning off for the box. ms6 slides 2/7: 4.97 -> 6.44
-> back to 4.97, 7.05 -> 8.10 -> 7.05.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 08bc46c0ea Text: underline styles (double, heavy, dotted, dashed, wavy) at the font's underline position
Every underline was a solid line 0.06 em thick at 0.12 em below the baseline, whatever a:rPr/@u
asked for. Underlines now sit at the font's own underline position and thickness (post table),
where PowerPoint draws them (Arial 96 pt: 10 px below the baseline, 7 px thick), and each style
is drawn as measured on PowerPoint's export: dbl two lines 0.55 thick spanning one thickness above
to the single line's bottom; heavy from one thickness above to 0.55 below; dotted dots and gaps one
thickness long; dashes three long with gaps of two; wavy a thin zigzag every 3.9 thicknesses. The
"words" style underlines spaces too, as PowerPoint draws it. Dashes and waves are new Deco line
kinds the renderer fills or strokes (warped text draws their boxes).

Underline slides: w14-xml s10 11.49 -> 10.43, s11 6.64 -> 4.06; w06-charfmt s3 9.66 -> 9.51,
s4 7.63 -> 7.44.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 ed562d7305 Text: centre, right and decimal tab stops align the text after them; a tab past the end wraps
Tab stops were all left stops: a:tab/@algn was read and saved but ignored. As PowerPoint draws
them, the text after a tab (up to the next tab or line break) now ends at a right stop, is
centred on a centre stop, and has its first "." on a decimal stop (text without one ends there);
when that text is wider than the room before the stop it starts where the tab is, never further
left. Measured on PowerPoint's export: "right" ends at 180 pt, "12.5" and "1234.567" put the
point on the stop, "12,5" and "nodot" end at it.

A tab whose stop lies past the end of the line now starts the next line, so the text after it
sits on that line's first stop, as PowerPoint wraps "abcdefghij<tab>k<tab>l<tab>m…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 ef313b91b0 Text: transparent text fills are faded once, not twice
A run's solid fill with a:alpha was resolved into the glyph colour's alpha and also kept as the
run's fill opacity, and the renderer multiplied both: text at 60 % transparency drew at 16 %
opacity instead of 40 %. The colour now carries the transparency alone. PowerPoint's export of
"Transparent 60% text" (red) is a light red that now matches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 9e2f7b96dc Text: small capitals draw at their size, and all caps keep characters like "ß"
Small capitals were drawn at the full size of the run's first character: glyph runs grouped
cells by style and face only, so "HxHx" drew every letter at the capital's size on the small
capitals' narrower advances (letters overlapped). A lowercase letter in small caps now starts a
run of its own at 80 % (PowerPoint's small capital "X" is 0.81 of the capital "H" in Arial).

All caps and small caps mapped "ß" to "S" (the first character of "SS"); PowerPoint leaves a
character whose capital is a sequence as it is ("ß", "fi", "ʼn"), and maps the rest one to one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 f3f959cabf Text: character spacing of 0.6 pt or more (or -1 pt) switches kerning off for the whole box
PowerPoint kerns pairs (a:rPr/@kern) only while no run of the text body is spaced out by 0.6 pt
or more or condensed by 1 pt or more; then no run of that box is kerned, even runs without
spacing. Measured on Arial 40 pt "AVATAR": spacing 0, 0.01, 0.5, -0.6 and -0.99 pt keep the pairs
(146 px wide), 0.6, 0.8, 1, 1.5 and -1 pt drop them in every paragraph of the box (158 px plus the
spacing). DeckCraft kerned spaced titles, making "AVATAR"-like words 12 px too narrow at 40 pt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Brandon ThomasandClaude Opus 5.5 cfd4f25545 Text: bidi resolves spaces, punctuation and digits by the run's language, as PowerPoint does
PowerPoint does not resolve neutral characters with plain UAX #9: the language of each run
(a:rPr/@lang) decides. Measured with every space highlighted in its own colour:
- in a run of a left-to-right language (or none) punctuation and digits are left to right and a
  space takes the direction of the character before it, so "abc HE1 HE2 def" shows the space after
  the Hebrew words on their left, and an English sentence in an RTL paragraph keeps its full stop
  at its end;
- in a Hebrew/Arabic run, neutrals between text of one direction take it, all others are right
  to left, and digits are a number in the right-to-left text unless left-to-right text precedes.

Paragraphs with explicit embeddings keep the full algorithm. Bidi probe deck: mean diff 11.1 ->
10.0 on the four mixed-direction slides; corpus poi 6.660 -> 6.654, others unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:48:43 -04:00
Momentum96 ad8fea51d2 ui: admit stock macOS CJK faces as interface fallbacks (#101)
The 48 MB cap on borrowed UI faces rejected macOS's only Hangul face (AppleSDGothicNeo.ttc, ~55 MB) and the picked Simplified Chinese face (PingFang.ttc, ~78 MB), so Korean and Chinese text in the title, recent files and notes drew as boxes. Raise the cap to 128 MB, as in PhotoCraft.
2026-10-13 19:27:56 +09:00
NikitaandCursor 591c06ba33 MCP tools refuse an argument that is not in their schema (#371)
A misspelled key used to be dropped, so the tool still succeeded with its defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:07 +03:00
NikitaandCursor 8b1836bc53 Effect Options marks the stored choice for animations too (#365)
The same rule that highlights a transition direction now highlights an animation's direction and text build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:10:24 +03:00
NikitaandCursor c238d933df Mark the current transition effect option (#365)
The Effect Options menu drew every direction as the same button, so the active one was invisible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:48:28 +03:00
NikitaandCursor 503ee5d67e A tabbed slide body is an outline, and --help exits (#360)
slide.new kept a leading tab as text, while text.set already turned it into a bullet level. The desktop --help flag was opened as a missing file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:38:57 +03:00
Brandon ThomasandClaude Opus 5.5 d5e30557eb Transitions like PowerPoint: measured frame by frame and retuned (#366)
Every transition and Effect Option was built in PowerPoint (COM), exported as
video and compared frame by frame with DeckCraft at the same progress
(mean abs diff over 150 variants 35.7 -> 15.0, 0-255).

- anim: transition_frame(kind, option, t, aspect) with measured curves
  (smoothstep fades, ease-in-out moves, Cover/Uncover/Pan curves), directions
  and option meanings, soft edges as per-corner opacity, 3-D effects as
  perspective-projected cards, Dynamic Content moving slide contents over
  cross-fading backgrounds (Source::OldBack/NewBack/OldContent/NewContent);
  layer_mesh subdivides perspective quads so textures stay straight.
- New options: Page Curl Single, Glitter Hexagons, Shred In/Out, Zoom and
  Rotate; PowerPoint-style Effect Options labels.
- render: composite_transition (CPU twin of the show), render_slide_part,
  render_transition; deckcraft-cli render-transition FILE --slide N --t T.
- show: per-vertex opacity meshes, slide parts rendered once per transition.
- pptx: PowerPoint's elements and defaults (p:strips diagonal wipes, p14:warp
  and p:newsflash for Zoom, p:zoom for Shape In/Out, glitter pattern, shred
  rectangle/dir, prstTrans invX per preset, pageCurlSingle, defaults for
  randomBar/doors/window/pan/zoom); PowerPoint reopens DeckCraft's files with
  the same effects.
- Tests: measured curves, probes of which slide shows where at t, option
  coverage, per-frame triangle budget, PowerPoint XML and defaults round trip.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 05:31:13 -04:00
pierrevbc 44a032d3de Tables: editing tests for tables with a file-defined style
A table whose style the file defines (its parts and background drawn
since #292 and #294) edits like the others: the caret is laid out with
the file style's lettering (a regular header where the built-in style
bolds it), a double-click selects the word drawn there, a click moves
the caret to the clicked cell and a border drag resizes the columns
while the cell stays edited.
2026-10-13 11:30:13 +02:00
pierrevbc 8392081976 Progress docs: table cell editing and border resizing 2026-10-13 11:30:13 +02:00
pierrevbc d63d250d58 Tables: Tab and Shift+Tab skip cells hidden under a merge
Tab moved into the cells covered by a merged cell, editing text that is
never drawn. It now goes to the next visible cell; Tab in the last
visible cell still adds a row, and Shift+Tab in the first cell stays.
2026-10-13 11:30:13 +02:00
pierrevbc 456c5c5373 Tables: drag row and column borders to resize them
Hovering a table border shows a resize cursor (new hit kinds
TableColumn and TableRow) and dragging it resizes, one undo step per
drag, as in PowerPoint:

- an inner column border moves between its two columns and the table
  keeps its width;
- the right edge widens or narrows the last column and the table;
- a row's bottom border (the last one is the table's bottom edge) sets
  that row's height, which stays a minimum: rows still grow to fit
  their text.

Columns stop at 12 pt. Borders hidden inside merged cells aren't hit;
the left and top edges still move the table. A border drag while a
cell is edited keeps editing it. The drag goes through
table.columnWidth, which takes a new keepTotal option (the next column
absorbs the change), and table.rowHeight. Hit testing is done in the
table's own frame, so rotated tables resize along their axes.
2026-10-13 11:30:13 +02:00
pierrevbc 70314fa828 Tables: clicks place the caret in the clicked cell
- A double-click in a cell puts the caret where clicked and selects the
  word there, as in shapes (it used to put the caret at the end).
- While a cell is edited, a click in the same table moves the caret to
  the clicked cell and position; a drag selects text within the cell
  and Shift+click extends only within it.
- A click in an already selected table enters the clicked cell at the
  click; dragging the table still moves it.
- The release that follows a double-click no longer re-enters the text
  at the click, which collapsed the word just selected (shapes too).

Cells are found from the drawn cell rects, so merged cells and rows
that grew to fit their text are hit where they are drawn.
2026-10-13 11:30:13 +02:00
pierrevbc b5a8642417 Tables: show the caret and text selection in the cell being edited
Editing a table cell outlined the cell but drew no caret or selection,
and Up/Down/Home/End fell back to paragraph moves, because the text
layout used for drawing, hit testing and caret movement only existed
for shapes.

text::text_frame now lays out the edited text as it is drawn and gives
the transform to the slide, for shapes and cells alike: a cell's text is
laid out in its rect (merges honoured) with the cell's margins, anchor
and the table style's look, through the renderer's own cell_body and
cell_rects. layout_for and tools::text_pos use it, and the canvas draws
the caret and selection from it. The cell outline follows merged cells.
2026-10-13 11:28:22 +02:00
pierrevbc 71ddbc59dc Progress docs: Export button in the title bar
- ui-parity.md: title bar row; status line and history.
- ROADMAP.md: progress log line. Quick Access toolbar / title bar commands stays P (no
  customization); no gaps.md entry covered this.
2026-10-13 11:27:10 +02:00
pierrevbc 7812c77cf3 Title bar: Export button next to Save
The Export dialog had no visible control: the native menu bar that lists
Export… is not installed, so only the command palette reached it. The
quick access row now has an Export button (the existing Export icon)
that opens the export dialog, enabled when a presentation is open.
2026-10-13 11:26:44 +02:00
pierrevbc 09d6a6c715 Progress docs: right click selects the shape under the pointer
- ui-parity.md: context menu row; status line and history.
- ROADMAP.md: progress log line. No checklist row changes score; no gaps.md entry covered this.
2026-10-13 11:26:29 +02:00
pierrevbc ab81f6bc73 Canvas: right click selects the shape under the pointer
A right click on an unselected shape opened the slide's context menu
(Layout, Reset Slide...) because only the primary button reached the
pointer tool; the shape had to be left-clicked first. The right-button
press now goes through Session::pointer as PointerKind::ContextClick
(`contextclick` on the control channel and MCP) and selects like
PowerPoint before the menu opens: an unselected shape is selected alone
(a text box or placeholder is not put into text editing, nothing is
dragged), a selected shape keeps the selection, multiple selection
included, and empty slide clears it. In the box whose text is being
edited, the caret and text selection stay, and the context menu offers
text items (Paragraph, Bullets, Numbering, Link) next to Cut, Copy and
Paste, which act on the selected text.
2026-10-13 11:26:20 +02:00
pierrevbc 8c5f101e68 Progress docs: drag the frame of edited text to move the box
- ui-parity.md: Direct manipulation row added; status line and history.
- ROADMAP.md: progress log line. The checklist rows involved (Text boxes, In-place text editing)
  are already D; no gaps.md entry covered this.
2026-10-13 11:26:11 +02:00
pierrevbc 9d185aeb68 Canvas: drag the frame of a text box being edited to move it
While a text box or placeholder is being edited, every press inside its
box placed the caret, so the box could not be moved without leaving
editing first. Like PowerPoint, a band on either side of the box edge
(the hit tolerance wide, in the shape's rotated frame) is now its frame:
hit_test reports Hit::Frame there, the canvas shows the move cursor, and
a press leaves text editing, selects the box and drags it as one undo
step. A click without a drag just selects the whole box. Inside the box
the caret and text selection work as before; resize and adjust handles
still win. The text-click zone now follows the rotated box instead of
its axis-aligned bounds.
2026-10-13 11:26:11 +02:00
pierrevbc daf612b8fd Progress docs: SmartArt text on its dsp:txXfrm rectangle
- file-format-parity.md: SmartArt row notes the text rectangle; status line and history.
- ROADMAP.md: progress log line. No checklist row changes score (Insert SmartArt stays P), and
  the gaps.md SmartArt entry (pre-drawn shapes only) still holds.
2026-10-13 11:24:04 +02:00
pierrevbc 8a4342757b SmartArt: text is laid out on its text rectangle (dsp:txXfrm), not in columns
A SmartArt drawing shape keeps its text rectangle in dsp:txXfrm, apart
from the shape's own box. The importer ignored it and laid the text out
in the shape itself. In list layouts the shape is a tall, narrow box
turned by 90 degrees, so the text came out as narrow vertical columns,
in the editor and in the exported PPTX.

The text rectangle uses the shape's coordinate space and is turned by
the shape's rotation plus its own:

- When it is the shape's text area shrunk by some margin, with the same
  orientation, the shape keeps its text and the margin is added to its
  insets.
- Otherwise (a rectangle reaching outside the shape, or turned against
  it) the text moves to a new shape without fill or outline, placed on
  the rectangle right above its shape in the diagram's group.

Both export as plain DrawingML (positive insets, an ordinary rotated
shape), which other readers lay out the same way.

Test: a SmartArt drawing built in the test, with a text rectangle wider
than its shape, a turned shape with upright text, and a rectangle
inside its shape; the laid-out text box is checked on import and after
a PPTX round trip.
2026-10-13 11:23:30 +02:00
NikitaandCursor 18e47d1af7 CLI: say that render --slide counts from 0 (#359)
--all names files slide-01.png onward, so --slide 8 on an 8-slide deck looked like an off-by-one. The index stays 0-based, matching slide.go, and the error now gives the range and the file names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:55:00 +03:00
lloydandClaude Opus 5.5 0f069ce351 Merge upstream/main into fix/chart-auto-axis
Upstream ce1c7b7 ("Charts: axis number formats, automatic major unit,
hidden axes") replaced the value-axis scaling this branch changed, and
already draws #144's chart as 0-45 in steps of 5. Take its structure and
API as they are (axis_scale with fixed bounds/unit and a room-based
interval count, Chart::axis_scale(secondary, max_intervals), axis_range,
combo charts' secondary axis) and drop this branch's auto_scale.

On top of it, the Office rules upstream lacked:

- Automatic minimum: zero only when every value is >= 0 and the smallest
  is below 5/6 of the largest; otherwise the first major unit at or below
  min - (max - min)/20. All-negative data mirrors it (the axis ends at
  zero only when the largest is above 5/6 of the smallest). Headroom is
  5 % of the data range, as documented. Chart::axis_scale feeds it the
  plotted extremes (stacked sums still count from zero, 100 % series are
  0-1); axis_range and value_range keep returning zero-inclusive ranges.
- A fixed limit beyond the data makes the automatic end give way instead
  of producing an inverted axis.
- The renderer stops values past the axis limits at the plot edge, on
  both value axes.

The model tests from this branch are ported to the new signature with
ten intervals of room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:33:37 +02:00
Brandon ThomasandClaude Opus 5.5 735e16d93c Charts: text at the sizes the file sets, as in PowerPoint (#358)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:30:41 -04:00
Brandon ThomasandClaude Opus 5.5 7d8bb05374 rustfmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:20:52 -04:00
Магда М.Г. ce1c7b7759 Charts: axis number formats, automatic major unit, hidden axes
Value axes showed raw numbers ("800000") with a fixed ~5-interval scale and
fixed label gutters, deleted axes were still drawn, and data labels ignored
their number format, so Russian business charts read "516600000" where
PowerPoint shows "516 600 000 ₽".

- New deckcraft_model::numfmt: Excel number format codes (sections for
  positive/negative/zero, quoted and escaped literals, _x padding, *x fill,
  [$sym-lcid] currency, [colour]/conditions dropped, 0/#/? placeholders,
  grouping with ',' or the locale's literal space, trailing-comma scaling,
  %, General) with digit grouping and decimal mark from the chart's
  language (c:lang: ru-RU -> "800 000", "12,5%").
- Reading: each value axis' numFmt, c:delete, scaling min/max and
  majorUnit, for the primary and a combo chart's secondary axis; data
  labels' numFmt (series, then group, else the source data's formatCode);
  the chart's c:lang. The primary value axis is the one on the left
  (bottom) and the secondary the one on the right/top or crossing at max,
  whatever the plot groups' order.
- Scale (Chart::axis_scale): fixed bounds and unit where given, else the
  data plus 5% headroom rounded out to the smallest 1-2-5 unit that leaves
  as many intervals as the axis has room for labels, as PowerPoint does
  (0-800 000 by 100 000, 0-7 000 by 1 000 on the sample deck).
- Rendering: labels in the axis format, gutters sized to the longest label,
  deleted axes draw no labels and take no room, data labels in their own
  format.
2026-10-13 04:20:52 -04:00
Магда М.Г. 5c82c08847 Charts: draw combo charts with columns, lines and a secondary axis
A combo chart (columns plus lines, often with the lines on a second value
axis) was read with each series' type but drawn as lines only, on one
scale, so a spend-and-clicks chart showed the clicks as a flat line along
the bottom and no columns.

- Reading: a plot group whose value axis isn't the first group's is on the
  secondary axis (Series::secondary_axis, now set).
- Model: Chart::series_kind, on_secondary and axis_range (per axis; a combo
  stacks only its stacked series).
- Rendering: bar-like series are drawn as bars (clustered or stacked among
  themselves), the others as lines/areas over them, each against its own
  axis; the secondary axis' labels go on the right. Line series use their
  line's colour and width (where PowerPoint keeps them), also in the legend.
  Long category labels wrap onto two lines.
2026-10-13 04:20:52 -04:00
Brandon ThomasandClaude Opus 5.5 67b7f82cff Chart legend test: a chart with a real namespace and a bar chart, as the reader needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:20:52 -04:00
MarekSpodBiedry b585027de2 Chart legend: honour legendPos and overlay in the renderer (#262) 2026-10-13 04:20:52 -04:00
natocool2andClaude Opus 5.5 0d6301cb7c Bump quick-xml to 0.41 (RUSTSEC-2026-0194, RUSTSEC-2026-0195)
RUSTSEC-2026-0194 (quadratic duplicate-attribute check) and RUSTSEC-2026-0195 (unbounded NsReader
namespace allocation) are fixed in quick-xml 0.41. deckcraft-pptx already reads attributes with
with_checks(false) and does not use NsReader, so neither is reachable today; the bump keeps the
workspace on a supported quick-xml and leaves one copy (wayland-scanner already locks 0.41.0).

decode_and_unescape_value() is deprecated in 0.41 (clippy -D warnings); decoded_and_normalized_value
(Implicit1_0, ...) is what the deprecated method now does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 09:07:44 +01:00
NikitaandCursor 5207844798 Credit Nikita Rakitin in the About window
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:55:10 +03:00
NikitaandCursor 71c5b1712a Charts: ignore relationships that point outside a chart's own parts (#335)
A chart relationship must not be able to replace a slide or another package part when the deck is saved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:53:08 +03:00
NikitaandCursor 8788f994ef Charts: keep the embedded workbook when a chart is saved unchanged (#335)
PowerPoint stores chart data in ppt/embeddings. Saving used to drop that part and the chart relationship, so the deck still rendered but the workbook was gone. An edit still omits the workbook rather than writing numbers that no longer match it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:40:59 +03:00
lloyd 720b66fbd3 Merge remote-tracking branch 'upstream/main' into fix-211-markdown-outlines
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-13 08:58:03 +02:00
Yevhenii Hurin 922445300b describe keeps a remark with its option and allows a bare call
animation.remove's remark belongs to its `ids` option, so describe shows
it there instead of as a note on the whole command. A command with an
all-optional set of parameters is suggested as a bare id.

[claude-opus-5-5] on behalf of hoblin
2026-10-13 09:55:19 +03:00
dotCooCoo 75c280d253 Contributors: add dotCooCoo (Robert Lee) 2026-10-12 22:32:53 -07:00
Marco Arnulfo 85345aaa13 Open: a password-protected presentation says so, and open errors show on the start screen (part of #346) 2026-10-13 06:44:47 +02:00
dotCooCoo 180cb4f36f Fonts: the font scan leaves out faces with no outline tables
The font scan also listed faces with none of the tables skrifa reads
glyph outlines from (glyf, CFF, CFF2, VARC): faces whose glyphs are
bitmaps only or are stored as outlines in Apple's hvgl table.
FontDb::outline, which the renderer reads glyph outlines from, returns
an empty path for every glyph of such a face. On macOS 27.0.1 the scan
lists "GB18030 Bitmap" (/System/Library/Fonts/Supplemental/NISC18030.ttf),
which stores its glyphs as bitmaps (bdat) and has no color glyph
tables, so the renderer draws none of the 27,544 characters it maps.

file_face_names now leaves out a face whose table directory has none of
those tables, as vectorcraft's font scan does (storytold/vectorcraft#781).
2026-10-12 21:43:39 -07:00
Marco Arnulfo e6a41d1ada Review: the Language dialog marks the selected text with the language you pick (fixes #339) 2026-10-13 06:04:28 +02:00
Brandon ThomasandClaude Opus 5.5 1660982e58 File tab: PowerPoint's File view (Home, New, Open, Info, Save As, Print, Export, Close) (#76) (#344)
* Engine: file.info and PowerPoint-style print slide ranges (#76)

file.info reports what File › Info shows (name, path, format, unsaved
changes, slide and hidden-slide counts, sections, size on disk,
properties). file.export's PDF `slides` also takes the range typed in
PowerPoint's Print page ("1-3, 5", 1-based); bad or out-of-range text is
an error instead of a silent full export.

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

* UI: the File tab (Backstage) like PowerPoint's (#76)

File (first ribbon tab) or Alt+F opens a full-window view in place of the
ribbon, slides and status bar: a side menu (Back, Home, New, Open, Info,
Save, Save As, Print, Export, Close; Account and Options at the bottom)
and the chosen page. Esc or Back returns; Up/Down walk the side menu and
show each page as it gets the focus; Enter runs Save/Close/Options.
Laid out after PowerPoint for Windows as observed: 160-pt menu a step
darker than the page, outline icons, current page in the accent colour.

The start screen is now File › Home (as in PowerPoint), with only Home,
New and Open at the top and no Back. Pages call existing commands and
file helpers: file.new {theme} and the sample deck (Home, New); recent
files with search, Browse and Recover Unsaved (Open); file.info and
file.properties, Accessibility Checker, Revert (Info); file.save /
file.saveAs / file.export by type (Save, Save As); a PDF with the Print
page's slides, layout (full page, notes, handouts 1-9), hidden slides and
frames, opened in the system viewer or downloaded on the web, with a
page preview (Print, Ctrl+P); Create PDF, Create Handouts and Change File
Type (Export); appearance and About (Account); Preferences (Options).
Share (cloud) is left out.

Close and Ctrl+W now ask Save / Don't Save / Cancel about unsaved
changes instead of closing at once. The Export dialog shares
SlideApp::export_as with the Export page. New UI commands: app.backstage
{page}, app.closePresentation, app.print; ui.inspect reports the page.
New icons drawn in code: back, save as, account, recent, new file, open
folder.

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

* Docs: File tab (Backstage) in ui-parity, gaps, checklist and progress log (#76)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:56:02 -04:00
lloydandClaude Opus 5.5 cc8e2eeb4b Merge storytold/main into fix-flaky-flipped-click-test
Conflict in crates/fonts/src/lib.rs: both sides added tests at the same
place; all three are kept. #322 independently serialised font loading in
try_family (loaded_families). This branch's loading lock and batched
add_fonts stay for the system-fallback path, but load_family no longer
skips a family with some face loaded: a lookup by a Windows family name
loads only that style's file, and the family's other files must load
later (windows_family_names_find_their_faces).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:02 +02:00
xc4pr b97af97a58 Text: the caret shows on dark slides and in empty placeholders
The caret was always stroked black. On a dark slide (the Nocturne theme,
or any dark background with light text) it could not be seen. It is now
drawn in the colour that text typed at the caret takes, and falls back to
the theme token when that text has no fill.

An empty placeholder showed no caret at all: its body holds no paragraph
until something is typed, so the edit layout had no line for the caret to
stand on. The edit layout now treats such a body as one empty paragraph,
and the canvas leaves the placeholder's prompt out while it is being typed
into, so the caret does not sit inside "Click to add title".

Fixes #318.
2026-10-13 00:39:17 +02:00
Yevhenii Hurin 426fd71bd2 ROADMAP status line names the progress-log entry
[claude-opus-5-5] on behalf of hoblin
2026-10-13 00:19:51 +03:00
Yevhenii Hurin 66f7e6e1ed Self-review: no ID[=JSON] left, params grammar documented
A --cmd without a value now asks for COMMAND[=PARAMS]. The params field
says how describe reads it, table.split's row and col are optional as
the command reads them, and the MCP instructions name only shape
commands for `ids`.

[claude-opus-5-5] on behalf of hoblin
2026-10-13 00:15:53 +03:00
Marco Arnulfo e3d9803069 macOS: end the process in on_exit so closing the window doesn't crash (fixes #279)
Same fix as storytold/gridcraft#117 (storytold/gridcraft#316): eframe drops
the window while AppKit's run loop still runs, AccessKit restores the
content view's class and AppKit's Touch Bar KVO observer throws on the
next display cycle. on_exit now ends the process on macOS once the
preferences are saved.
2026-10-12 23:12:03 +02:00
Yevhenii Hurin 717e855cec deckcraft-cli help explains the CLI to an agent
`deckcraft-cli help` and each subcommand's `--help` say what it does,
with a working example; `--help` never runs the subcommand. `describe`
spells out a command's parameters and what it acts on, `commands` lists
one command per line, and the MCP instructions say what format.* acts on.

[claude-opus-5-5] on behalf of hoblin
2026-10-12 23:41:42 +03:00
FabPei 28f51bf8ad fix: DeckCraft upstream #104
Make text in grouped shapes editable: double-click reaches the
text-holding group member instead of being a no-op.
2026-10-12 22:12:42 +02:00
Ryan C. ShoemakerandClaude Opus 5.5 837e60ce65 Contributors: add my name (RCShoemaker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:42:53 -06:00
Ryan C. ShoemakerandClaude Opus 5.5 30a5ee202d fix(cli): render refuses an unknown option instead of writing it as a file (#235)
`deckcraft-cli render` took any argument it didn't recognise as the output
path, so `render deck.pptx --help` rendered the slide into a file named
`--help`, and a mistyped option was silently ignored. An argument starting
with `-` that render doesn't know is now an error naming it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:42:53 -06:00
Магда М.Г. bb53f8c741 Text: honour spcFirstLastPara (space before the first paragraph)
bodyPr spcFirstLastPara="1" makes the first paragraph's space before and
the last paragraph's space after count, which PowerPoint (and Google
Slides exports) use; we always dropped them, so such text sat a paragraph
gap too high (9.5 pt on a real deck measured against PowerPoint's PDF).
Read, written, inherited from the layout like the other body properties,
and applied in the layout.
2026-10-12 16:22:12 +02:00
Магда М.Г. 2a8c5b2b27 Text: line spacing reduction comes off the percentage, not the spacing
PowerPoint's lnSpcReduction takes percentage points off a paragraph's line
spacing (90 % less 20 % is 70 %) and leaves the space before and after
paragraphs alone. We multiplied (90 % x 0.8 = 72 %) and shrank the
paragraph spacing too, so shrunk text came out with different line and
paragraph pitches than PowerPoint's: measured on PowerPoint's PDF of a real
deck, a body at 90 % spacing, 10 pt before paragraphs and a 20 % reduction
runs at 20.2 pt lines with 9.9 pt between paragraphs.
2026-10-12 16:22:11 +02:00
Магда М.Г. 1eb7ec83ae Text: draw a saved shrink scale as it is
PowerPoint fits shrink-on-overflow text again only when the text changes,
never when a file opens: it draws the saved fontScale/lnSpcReduction even
where our metrics would call the text overflowing (trailing empty
paragraphs at full size, substituted fonts). Shrinking further on open made
saved decks text smaller than PowerPoints (74 % instead of 85 % on a real
deck). With no saved scale, or once an edit has reset it, the text is
fitted as before.
2026-10-12 16:22:10 +02:00
Магда М.Г. fa2de347c4 Text: exact line spacing ends at the last line's descent
With exact line spacing (a:lnSpc/a:spcPts) PowerPoint puts the baseline at
80 % of the line pitch and ends the text at the last line's descent, not a
full pitch below it. We drew every line a full pitch tall, so table rows
whose cells use exact spacing grew taller than PowerPoint's (35.6 pt
instead of 33 pt for 12 pt type at 26.59 pt), pushing tables and text
boxes down and tripping shrink-on-overflow early.

Measured on PowerPoint's own PDF of a real deck: rows now come out at
33.5 pt against PowerPoint's 32.9-33.8 pt.
2026-10-12 15:59:35 +02:00
Isaac Levine 31f5446641 Docs: document --automation-read-root and --automation-write-root for mcp 2026-10-12 09:58:12 -04:00
Isaac Levine f3ae32d920 CLI: add --automation-read-root and --automation-write-root to mcp 2026-10-12 09:58:07 -04:00
Isaac Levine a541dad33d MCP: confine the headless session to the automation roots 2026-10-12 09:58:07 -04:00
Isaac Levine dc6ea04a8f Engine: check automation read and write roots on every path a command reads or writes 2026-10-12 09:58:07 -04:00
justinjohn0306andClaude Opus 5.5 aa65bb8af3 Animations: PowerPoint's Float Down (preset 47) imports as Float, down (#147)
PowerPoint writes Float In / Float Out with the Float Down option as its
own preset, 47 (Descend), not as preset 42 with a direction. DeckCraft
only knew 42, so a Float Down entrance or exit imported as a plain fade,
which is what the issue's video shows: the buildings fade out in place
instead of sinking. Preset 47 now imports as float / floatOut with the
"d" option and is written back as 47.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:31:10 +05:30
Магда М.Г. f2c27cbba1 Text: draw shrink-on-overflow at the scale the file was saved with
PowerPoint draws normAutofit text at its saved fontScale/lnSpcReduction;
we ignored them and only shrank when our own layout overflowed, so saved
decks showed larger text and wider line spacing than PowerPoint, with text
running past placeholders. The layout now starts from the saved scale
(nonsense values ignored) and still shrinks further when the text doesn't
fit. Editing the text resets the saved scale so the new text is fitted
afresh, as PowerPoint refits on edit.
2026-10-12 14:52:34 +02:00
Магда М.Г. 8ac6dc2f47 Render: an unfilled shape's glow follows its line
A glow always filled the shape's whole silhouette, so a frame with no fill
(a rounded-rectangle border with a soft glow, common in corporate decks)
came out as a solid tinted panel over the slide. PowerPoint glows such a
shape along its outline only and leaves the inside clear; a filled shape's
glow is unchanged.

Checked against PowerPoint's own PDF export of 31 real decks (597 slides):
the affected slides' mean pixel difference drops from ~29 to ~4 (of 255).
2026-10-12 14:52:32 +02:00
justinjohn0306andClaude Opus 5.5 b22d88c845 Notes and Presenter View: long notes scroll, presenter controls show (#221)
Long speaker notes grew out of the notes pane and over the slide: the
editor now scrolls inside the pane. In Presenter View the Previous, Next
and End Show buttons drew dark text on the dark window, so they looked
missing; they are now light on a visible fill, and the timer gets Pause /
Resume and Reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:18:51 +05:30
Marco Arnulfo 0167c0c31f Title bar: on macOS the window buttons sit centred in it (#71)
AppKit draws the traffic lights 16 pt from the top of the window on macOS 27,
but the title bar was 38 pt, so they sat above its centre line. On macOS the
bar is now 32 pt, centred on them: content starts after the lights with the gap
they keep from the top, and the Present button shrinks to 22 pt to keep a
margin. Same approach as wordcraft #255 and gridcraft #323, with the light
metrics measured on macOS 27. Linux, Windows and web are unchanged.
2026-10-12 14:47:41 +02:00
justinjohn0306andClaude Opus 5.5 bd61308cf9 Animations: nine PowerPoint exit effects import as themselves (#147)
Exit effects DeckCraft had no entry for (Peek Out, Strips, Blinds, Box,
Checkerboard, Diamond, Sink Down, Spinner, Contract) imported as a plain
Fade and lost their direction, so a Peek Out or Sink Down didn't move.
Each mirrors an entrance DeckCraft already plays, so it is played as that
entrance in reverse (Sink Down as Rise Up, leaving downward); the import
keeps PowerPoint's direction and saving writes a matching filter or
motion. Float Out now also writes its motion.

The preset ids and directions come from PowerPoint: every effect, as
entrance and exit, in every direction it accepts, saved and read back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:59 +05:30
lloydandClaude Opus 5.5 9c64754f20 Read Markdown outlines as Markdown (#211)
File ▸ Open's Outline filter accepts `.md`, but a `.md` was read with the
plain outline format (unindented line = slide, indent = bullet), so a
Markdown outline written the usual way kept `#` in its titles and made
every top-level list item a slide of its own.

`deckcraft_format::markdown_to_slides` reads ATX headings (`#`–`######`,
closing `#`s dropped) as slide titles, list items (`-`, `*`, `+`, `1.`,
`1)`) as bullets nested by their indentation, and other lines under a
heading as body paragraphs. A `.md` without headings is still read as a
plain outline, so existing indented `.md` outlines open as before; `.txt`
is unchanged. Opening a `.md` and dropping one on the window
(`slide.fromOutline {markdown: true}`) use it. Both readers now share the
slide builder.

Tests: the outline from the issue (2 slides, nested bullets), numbered and
`*`/`+` lists with text under a heading, `slide.fromOutline` with
`markdown`, plain `.txt`/`.md` outlines unchanged, and heading / list-item
edge cases (not-a-heading forms, closing hashes, Unicode, long digit runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:22:47 +02:00
justinjohn0306andClaude Opus 5.5 aaec22c681 PPTX: a lone guide keeps the name its preset uses, adj1 or adj (#214)
The writer named a preset's only guide `adj` whenever the preset has one
adjust value, but bentConnector3, curvedConnector3, frame, mathPlus,
mathMinus and mathMultiply call it `adj1`, and PowerPoint refuses to open
a file that renames it. The names come from PowerPoint itself: every
autoshape and connector created with an adjusted first value and saved;
these six are the only single-guide presets named `adj1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:30:03 +05:30
justinjohn0306andClaude Opus 5.5 21249df5da PPTX: .potx and .ppsx declare a template and slide show main part (#210)
Saving with a .potx or .ppsx name wrote a byte-identical .pptx: the
main part was always presentationml.presentation.main+xml, because
format_for_path folded all three extensions into "pptx". It now keeps
potx/ppsx, and deckcraft_pptx::export_as picks the template or
slideshow content type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:21:58 +05:30
justinjohn0306andClaude Opus 5.5 f58f4f425f PPTX: legacy OLE objects are saved in PowerPoint's fallback form (#203)
A legacy OLE object finds its preview in the slide's VML drawing through
spid; DeckCraft doesn't write that drawing, and PowerPoint refused to
open a file whose oleObj has a spid but no VML shape. The reader now
takes the preview picture from the VML drawing (so DeckCraft shows it
too), and the writer drops spid and adds that preview as a p:pic, the
form PowerPoint itself writes as the fallback.

Checked in PowerPoint: the issue's 45545_Comment.pptx saved by DeckCraft
opens with its three embedded objects and draws slide 8's table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:17:55 +05:30
justinjohn0306andClaude Opus 5.5 c462859f6f PPTX: SmartArt and OLE objects we can't draw keep their parts on save (#203)
An object DeckCraft doesn't model kept its XML, but the parts that XML
points to (diagram data, layout, style and colours; an OLE embedding)
were not kept, so a save wrote it as an empty rectangle or its preview.

Opaque now carries those parts (relationship id, full relationship type,
the part as a media item) and the writer re-adds them under the slide's
new relationship ids. A part with relationships of its own, or an
external link, still falls back to the preview with the existing
warning. A diagram's data part loses its link to the cached drawing,
which is a slide relationship id a save renumbers; only diagrams whose
drawing is missing or empty reach this path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:09 +05:30
justinjohn0306andClaude Opus 5.5 99c1e062aa Home: a Selection Pane button that works with nothing selected (#132)
The Selection Pane was only under Home ▸ Arrange, which is disabled until
a shape is selected, so it couldn't be opened on an empty selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:20:48 +05:30
justinjohn0306andClaude Opus 5.5 2ff4285be6 Pictures: Change Picture replaces the selected picture (#115)
The Picture Format ▸ Change Picture button ran the insert-picture dialog,
so it added a second picture instead of replacing the selected one. A new
app.changePictureDialog picks a file and runs picture.change, which keeps
the picture's position, size and formatting; the canvas right-click menu
offers it on pictures too. On the web, where the file arrives later
through the inbox, the pending change is remembered for that pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:12:55 +05:30
justinjohn0306andClaude Opus 5.5 1601fd250a Slide Size: choose Portrait or Landscape (#198)
The Slide Size dialog gets an Orientation choice that swaps width and
height, and design.slideSize takes orientation: portrait|landscape so a
preset can be made portrait from the CLI and MCP too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:39:40 +05:30
Isaac Levine be019fd615 Slides: Ctrl+A in the slide pane selects every slide (#188)
The UI remembers whether the last press landed in the thumbnail pane (the sorter always counts) and passes scope slides to edit.selectAll in that case.
2026-10-11 22:53:29 -04:00
Isaac Levine 96abeba71b Slides: edit.selectAll takes scope slides to select every slide (#188)
The current slide stays put and any shape or text selection is cleared. A scope other than slides is an error; without a scope the command still selects shapes.
2026-10-11 22:53:29 -04:00
Claudio d5c62b640a Ctrl+C then Ctrl+V pastes objects that carry no text (Fixes #182)
egui-winit drops Ctrl+V when the system clipboard is empty. Copying a
rectangle or picture puts no text on it, so the shortcut never reached
the session and only the context menu could paste objects. Copy and cut
now leave an invisible word joiner on the system clipboard in that case,
and paste maps it back to the session's own clipboard.
2026-10-11 19:54:21 -06:00
MarekSpodBiedry dd404af8b5 PDF: map each glyph to its shaping cluster's text in ToUnicode (#164) 2026-10-11 23:39:32 +02:00
MarekSpodBiedry c1ab5d3c43 CLI: --help prints usage and unknown render flags are rejected (#163) 2026-10-11 23:37:38 +02:00
Matúš LacoandClaude Opus 5.5 c871c5f634 Export: animated GIF of the deck (Export… dialog, CLI, MCP) (#171)
There was no way to turn a presentation into an animated GIF. file.export now writes one: format
`gif` (or a .gif path), one frame per slide drawn by the slide renderer and shown for
secondsPerSlide, encoded with the `image` crate's GIF encoder (already a dependency, no new
crates) with a 256-colour palette per frame: exact when a slide has 256 colours or fewer,
NeuQuant otherwise. Slides are composited onto white.

Options: width (small 640, medium 960 (default), large 1280, extraLarge 1920, or 16-3840 px; the
height follows the slide's aspect ratio, no side over 3840), secondsPerSlide (0.1-600, default 5,
written in GIF hundredths), loop (forever by default, or once: no NETSCAPE2.0 block), slides (a
list of indices, in play order) and includeHidden (hidden slides are skipped by default).
Out-of-range or wrongly typed values, NaN and infinities are BadParams errors, never panics; at
most 500 frames and 200 million pixels in total, checked before anything is rendered.

- Engine: crates/engine/src/gif_export.rs (options, frame list, size, encoder);
  cmd::file::gif_bytes, save_bytes("gif"), format_for_path(".gif").
- UI: the Export… dialog (Command palette; the app has no File menu yet) gets "Animated GIF (.gif)" with Size, Seconds per slide, From/To slide,
  Loop forever and Include hidden slides; an invalid range or value keeps the dialog open with a
  message. The web build downloads the bytes (image/gif).
- CLI: convert IN OUT.gif, run --export OUT.gif --export-params JSON; usage lists the format and
  options.
- MCP: the export tool lists gif and width/secondsPerSlide/loop (slides and includeHidden now
  apply to PDF and GIF).
- Docs: file-format parity GIF row, gaps (video export remains), parity checklist row M -> P
  (P2 breadth 36% -> 37%, overall 79%), control protocol, MCP, README, ROADMAP progress log.

Transitions and build animations are not animated in the GIF (tracked with video export).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:00:33 +02:00
Fabio LussiandClaude Opus 5.5 b14cf1da70 Windows: compile DX12 shaders with FXC
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds. DeckCraft ships none, and without a matching dxil.dll the
unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the device
is lost and the app exits before the first frame. Pin the compiler to FXC,
which is part of Windows; WGPU_DX12_COMPILER still overrides it.

Fixes #153

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:25:06 +02:00
Hevar Hemin f033c76913 Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly
When typing in Kurdish, Arabic or Hebrew in a new document or text shape, default
paragraph direction (props.rtl: None) was resolving to LTR. In an LTR
paragraph, visual reordering pinned newly typed characters and the caret to the
left margin, resetting to the start of the line with every keystroke and jumping on
space characters due to UAX #9 rule L1.

- Add first_strong_is_rtl() in deckcraft-model following UAX #9 rules P2/P3.
- Auto-resolve unassigned paragraph direction to RTL in shape_para layout when the
  paragraph starts with an RTL script, defaulting alignment to Align::Right.
- Auto-promote paragraph to RTL (props.rtl = Some(true), props.align = Some(Align::Right))
  when typing or inserting text starting with an RTL character.
- Add regression tests in deckcraft-model, deckcraft-text, and deckcraft-engine.
2026-10-11 22:28:45 +03:00
Matúš LacoandClaude Opus 5.5 1ce5f686e8 Notes: speaker notes show their formatting and keep it when edited
Notes from a .pptx were read and written with their formatting, but the notes pane and Notes
Page drew them as plain text (Slide::notes_text in a plain TextEdit), and any edit there saved
the text back through slide.notes as TextBody::from_text, dropping every bold run, level,
indent and bullet of the slide's notes.

- ui-egui: a notes editor (notes.rs) shared by the notes pane and Notes Page. It draws bold,
  italic, underline, strikethrough and size from the runs, and each paragraph's margin, hanging
  indent and bullet or number (Wingdings/Symbol bullets mapped as on slides); paragraphs are
  laid out one by one so wrapped lines align with the text, not the bullet. Each edit is applied
  to a formatted draft, so typed text takes the formatting where it lands (Enter in a bulleted
  point starts another), and the draft is saved once the field loses focus or the slide changes.
- engine: slide.notes takes `body` (formatted notes, as slide.inspect's new `notesBody`), and
  `text` now changes only what was retyped (model edit::replace_text); text sharing nothing
  with the old notes is still plain.
- text: map_symbol_bullet is public for the notes editor.

PDF notes pages still print the notes as plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:21:33 +02:00
MarekSpodBiedry a3fae2c031 Ribbon: transition and animation tiles follow the dark theme (#159) 2026-10-11 19:13:27 +02:00
lloydandClaude Opus 5.5 6a91164a5b Issue #144: automatic value axes scale like Office
The value axis used a generic "nice" scale: about five intervals and no
headroom, so data with a maximum of 42.1 got 0-50 in steps of 10, while
PowerPoint and LibreOffice draw 0-45 in steps of 5.

`auto_scale` now follows the rule Office documents for automatic limits:
the maximum is the first major unit at or above max + (max - min)/20, the
minimum is zero when all values are positive and the smallest is below 5/6
of the largest (otherwise the first major unit at or below min - the same
headroom), and all-negative data mirrors that. The major unit is the
smallest 1, 2 or 5 x 10^n that gives at most 10 intervals. Fixed
`<c:min>`/`<c:max>` limits are kept as they are and no longer get rounded
or padded; values beyond them stop at the plot edge. 100% charts get
0-1 in steps of 0.1.

Fixes #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:05:17 +02:00
lloydandClaude Opus 5.5 e999424c79 Issue #106: zooming keeps the point under the pointer
Ctrl/Cmd + wheel and pinch changed the zoom but left the scroll offset
alone, so the slide grew from the canvas corner (from its top-left once it
was larger than the window) and the spot under the pointer slid away. The
pointer position was read but never used. Zoom In/Out, the status-bar
slider and the Zoom dialog had the same effect.

The canvas now remembers last frame's slide rect and scale. When the scale
changes it sets the offset so the slide point under the anchor stays put:
the pointer for wheel and pinch zoom, the middle of the canvas for the
commands. Once the slide fits the window it is centred as before. Wheel
and pinch zoom take effect in the same frame instead of the next.

Fixes #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:57:53 +02:00
MarekSpodBiedry 1c8992fdbe Charts: a missing varyColors varies single-series bar colours by point (#143) 2026-10-11 13:54:44 +02:00
MarekSpodBiedry 44ddb579da Charts: a single-series chart without an explicit title shows the series name as its automatic title (#142) 2026-10-11 13:53:47 +02:00
Alexander Alber 5ac5223946 PPTX import: a transition without spd is 0.5 s, not the effect's default (#117)
PowerPoint 2021/2024 write a 0.5 s transition with neither spd nor
p14:dur, since "fast" is the schema default for spd. The reader fell back
to the effect's own default instead (Morph 2 s, Fade 0.7 s, most 1 s).

generated with Claude Code
2026-10-11 13:40:58 +02:00
Alexander Alber 208c163647 Slide show: media set to play in the click sequence starts on Next (#116)
PPTX import already read PowerPoint's media effects (presetClass
mediacall: Play, Pause, Stop) into the timeline, but the show never
carried them out: media started only with autoplay or a click on it.
The show now sets off a step's media effects as the step reaches them,
and also when a click cuts the step short.

A step also no longer lasts as long as the media it starts: the video
plays on by itself and the next click goes on at once. An After Previous
effect behind it still waits for the media to end.

generated with Claude Code
2026-10-11 09:55:36 +02:00
Alexander Alber 3bb284cfdd Text: typing goes on after Tab in a text box (#78)
The engine handled Tab (a tab character, or a list level at the start of
a paragraph), but egui also treated it as focus navigation: with no
widget focused it focused the first ribbon widget, and since the slide
only takes keys while nothing is focused, every key after Tab was lost.
When the slide has the keyboard, the keyboard handler now cancels that
focus move.

This also covers the "can't type after indenting" part of #93.

generated with Claude Code
2026-10-11 09:26:42 +02:00
Alexander Alber 7db770d91d Morph: a picture cropped differently on the next slide morphs its crop (#118)
Two paired pictures that differed only in crop counted as changed
content, so the new one faded in over the old one on the moving box.
Now they stay one picture whose crop morphs: the whole image moves and
scales linearly between where it lies on either slide, and each frame's
crop is what the interpolated box shows of it (morph_crop).

generated with Claude Code
2026-10-11 09:16:27 +02:00
Alexander Alber 04069e1968 Morph: text boxes without a partner fade in place instead of moving (#119)
The last matching pass paired any two shapes of one kind and preset
geometry, so a text box leaving the slide was paired with an unrelated
one arriving and flew across to it. That pass now only pairs shapes
without text: a text box needs its name or its text on the other slide
to morph, otherwise it fades out (or in) where it is, as in PowerPoint.

generated with Claude Code
2026-10-11 09:03:01 +02:00
Dylan M. Taylor 35d265891c docs(readme): percent-encode middle dot in platforms badge url 2026-10-10 17:28:25 -04:00
Mikkel Georgsen 736dd52b51 Commands: an invalid shape id is an error instead of the selection
A shape id that was present but not a valid id (negative, not an integer,
larger than u32) was treated as absent, so commands such as text.set and
text.edit silently edited the selected shape instead. id_param now tells three
cases apart: absent (the selection fallback is unchanged), valid, and invalid
(an error naming the command). arrange, edit (select), inspect, review, table,
text and the animation painter pick this up.
2026-10-10 22:05:22 +02:00
Mikkel Georgsen d34b5df775 Slides: going to a slide that does not exist is an error
slide.go clamped an out-of-range index to the last slide and reported
success, so a request for slide 99 of a short deck landed on the last slide
without a word. It now returns an error and stays on the current slide. A slide
id beyond u32 is reported as an unknown slide instead of being read as id 0.
2026-10-10 22:05:13 +02:00
Mikkel Georgsen 20366a8ea9 Export: an unknown format is an error instead of a deckcraft file
file.export with an explicit format outside png, jpeg, jpg, pdf, pptx, potx,
ppsx, deckcraft, outline and txt fell through to the native deckcraft zip, so a
request for "docx" reported success and wrote a deckcraft archive under that
name. It now returns an error and writes nothing. A format inferred from the
file extension behaves as before.
2026-10-10 22:04:58 +02:00
MarekSpodBiedry cebf667579 Paste: Ctrl+V pastes an image-only clipboard as a picture (#53) 2026-10-10 19:09:02 +02:00
MarekSpodBiedry 7afa9ea133 Command palette: one Export… entry that opens the dialog, no path-only duplicate (#79) 2026-10-10 19:03:28 +02:00
MarekSpodBiedry d2acdf07db Comments: browser-created comments and replies get a creation timestamp (#72) 2026-10-10 19:01:56 +02:00
MarekSpodBiedry 6ff5b274ea PPTX: animation rewind survives save and reopen (#77) 2026-10-10 19:00:06 +02:00
MarekSpodBiedry 6be9817693 Export: PNG images (all slides) in the browser downloads every slide (#64) 2026-10-10 18:59:34 +02:00
lloydandClaude Opus 5.5 074e090f05 Fonts: a lookup finds an installed family whichever thread loads it first
Two threads resolving a family nobody had loaded yet raced. The one that
lost the race saw add_font add nothing and fell back to another face, and
a lookup made while a family was being added saw only part of it (Arial
Bold Italic loads first, so "Regular" came back bold italic). The same
text was then laid out in different fonts depending on timing, which made
clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn flaky when the
theme font isn't bundled.

Loading installed fonts now happens under one lock and rechecks once it
holds it, and a family's faces are added in one step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:50:21 +02:00
Alexander Alber a6000f18fd Morph: align an outline morph once per transition, not every frame
Follow-up to #27. render_blend built a GeometryMorph (contours, alignment)
for every morphing shape on every frame, though only t and the current box
change while a transition plays. The Renderer now keeps the aligned morphs,
keyed by both geometries and their own sizes, and drops those a frame did
not use, so the cache ends with the transition.

- render: morph_path::Cache replaces morph_path::geometry; blend sweeps it.
- test: one alignment per transition, same geometry as a fresh one.

generated with Claude Code
2026-10-10 12:51:31 +02:00
421 changed files with 86191 additions and 15176 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+18 -44
View File
@@ -1,54 +1,28 @@
name: ci
name: CI
# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings),
# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`.
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Same craft-fonts commit as release.yml (Latin presentation fonts).
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gates:
name: gates (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ci-${{ matrix.os }}
- name: cargo xtask ci
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
packages: >-
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
libgl1-mesa-dev
+6 -5
View File
@@ -2,16 +2,17 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow itself still run it.
on:
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
schedule:
- cron: "27 2 * * *"
workflow_dispatch:
concurrency:
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# 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/nix.yml@main
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+31
View File
@@ -0,0 +1,31 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
# Nightly, so dependency and runner-image drift shows up within a day.
schedule:
- cron: "3 5 * * *"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: deckcraft
app-dir: DeckCraft
+2 -1
View File
@@ -131,11 +131,12 @@ Standard: craftrules `standards/never-crash.md`.
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, layering check, asset check, wasm
`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`.
+19
View File
@@ -7,6 +7,8 @@ fails if a file under `assets/`, `docs/images/` or `docs/brand/` is missing from
DeckCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, fonts, themes,
templates or other assets**. Its themes, colour schemes, layouts, preset-shape geometry, sample
deck and UI icons are original work of the DeckCraft contributors (drawn or generated in code).
Icons placed from Insert › Icons are Lucide paths (ISC; Feather-derived icons MIT), not Microsoft
artwork. The ribbon and menu icons stay original drawings.
## In this repository
@@ -26,11 +28,14 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
| Lucide icon paths in `crates/engine/src/icon_data.rs` (Insert › Icons) | Lucide Contributors; Feather icons by Cole Bemis | https://github.com/lucide-icons/lucide/tree/440a1e1dfb1e712505308da46bead3c28b1f1398/icons (release 1.56.0, 2026-10-13) | ISC; MIT for the Feather-derived icons named in that commit's LICENSE | Stroke icons inserted as shapes |
## Bundled through dependencies
@@ -56,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
## PDF writer colour profiles
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
| Asset | Author | Source | Licence | SHA-256 | Used for |
|---|---|---|---|---|---|
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
Generated
+153 -200
View File
@@ -444,30 +444,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec 0.9.1",
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -605,6 +590,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.6.0"
@@ -814,6 +805,23 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -877,15 +885,17 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"arboard",
"chrono",
"cpal",
"deckcraft-engine",
"deckcraft-media",
"deckcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"image",
"log",
"rfd",
@@ -896,7 +906,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -905,7 +915,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -914,31 +924,34 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"proptest",
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
"serde_json",
"zip",
]
[[package]]
name = "deckcraft-color"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"chrono",
"deckcraft-anim",
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-format",
@@ -951,15 +964,17 @@ dependencies = [
"deckcraft-text",
"i_overlay",
"image",
"js-sys",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "deckcraft-fonts"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -971,7 +986,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -982,7 +997,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"kurbo",
"serde",
@@ -990,7 +1005,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -999,7 +1014,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1008,19 +1023,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1030,7 +1047,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1048,15 +1065,16 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"image",
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-model"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1068,30 +1086,33 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.6.1"
version = "0.10.0"
[[package]]
name = "deckcraft-opus"
version = "0.6.1"
version = "0.10.0"
[[package]]
name = "deckcraft-pdf"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
"deckcraft-model",
"deckcraft-render",
"deckcraft-text",
"hayro-syntax",
"krilla",
"kurbo",
"log",
"serde",
"thiserror 2.0.21",
"wasm-bindgen-test",
]
[[package]]
name = "deckcraft-pptx"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1099,7 +1120,7 @@ dependencies = [
"deckcraft-model",
"image",
"log",
"quick-xml 0.38.4",
"quick-xml",
"serde_json",
"thiserror 2.0.21",
"zip",
@@ -1107,8 +1128,9 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
@@ -1125,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1139,7 +1161,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1161,7 +1183,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1170,7 +1192,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -1581,12 +1603,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fearless_simd"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1614,10 +1630,15 @@ dependencies = [
]
[[package]]
name = "fnv"
version = "1.0.7"
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "foldhash"
@@ -2000,7 +2021,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
dependencies = [
"fearless_simd 1.1.0",
"hayro-ccitt",
]
@@ -2009,9 +2029,6 @@ name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
dependencies = [
"fearless_simd 0.4.1",
]
[[package]]
name = "hayro-syntax"
@@ -2019,26 +2036,13 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
dependencies = [
"flate2",
"hayro-jbig2",
"hayro-jpeg2000",
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"zune-jpeg",
]
[[package]]
name = "hayro-write"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
dependencies = [
"flate2",
"hayro-syntax",
"pdf-writer",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2247,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
"quick-error",
]
[[package]]
@@ -2425,20 +2429,19 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
"pdf-writer",
"png",
"rayon",
"rustc-hash 2.1.3",
"rustybuzz",
"siphasher",
@@ -2582,6 +2585,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2625,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -2692,6 +2705,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -3089,6 +3111,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
@@ -3361,15 +3389,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -3400,52 +3419,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.13.2",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3476,44 +3461,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3628,12 +3575,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
@@ -3720,18 +3661,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -4369,7 +4298,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"quick-error",
"weezl",
"zune-jpeg",
]
@@ -4516,12 +4445,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -4618,7 +4541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4633,7 +4556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4677,15 +4600,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4761,6 +4675,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -4880,7 +4833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@@ -4975,8 +4928,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bit-set",
"bit-vec",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -5048,7 +5001,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
@@ -5666,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.1"
version = "0.10.0"
dependencies = [
"serde_json",
]
+16 -5
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.6.1"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/deckcraft"
[workspace.dependencies]
@@ -49,7 +49,9 @@ rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
# per-crate for targets that read the local clock (see crates/text).
chrono = { version = "0.4", default-features = false, features = ["std"] }
# text / rendering / formats
skrifa = "0.47"
@@ -57,9 +59,10 @@ 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"] }
krilla = { version = "0.8", features = ["pdf"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
i_overlay = "9"
quick-xml = "0.38"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
@@ -82,14 +85,22 @@ self_named_constructors = "allow"
large_enum_variant = "allow"
result_large_err = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
+53
View File
@@ -19,6 +19,51 @@ list (author, source, license) is in ATTRIBUTION.md.
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
are MIT, Copyright (c) 2013-present Cole Bemis; which names those are is the list in Lucide's
LICENSE at that commit. Among the paths embedded here, the Feather-derived names are arrow-down,
arrow-left, arrow-right, arrow-up, at-sign, calendar, check, chevron-right, clipboard, clock,
compass, download, info, key, link, lock, minus, moon, move, music, plus, search, target and x.
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The MIT License (MIT) (for the Feather-derived icons)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Code dependencies
@@ -27,3 +72,11 @@ Code dependencies
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).
- 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
and rejection of serialization after failed cleanup. Its upstream licence files are
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
+59 -11
View File
@@ -7,15 +7,19 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
</p>
<h1 align="center">DeckCraft</h1>
<p align="center">
<b>Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.</b>
<b>Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.</b>
</p>
<p align="center">
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
(through MCP) can drive.
</p>
@@ -24,7 +28,10 @@
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f26b1d?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-b7410e?style=flat-square">
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20·%20Windows%20·%20Linux%20·%20BSD%20·%20Web-555?style=flat-square">
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-555?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_deckcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/deckcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -74,8 +81,8 @@ Today it can:
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
MCP server for AI agents.
@@ -97,6 +104,9 @@ Today it can:
## Getting started
PDF export uses the shared `craft-pdf` writer with import features disabled. See
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
```sh
git clone https://github.com/storytold/deckcraft
cd deckcraft
@@ -129,7 +139,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
Parts skipped on import and audio output problems land there. Each launch moves the
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
@@ -150,12 +160,13 @@ deckcraft-cli commands format. # list commands
deckcraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.deckcraft
deckcraft-cli mcp # MCP server over stdio (headless)
deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --control 7990)
deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # confine the headless agent to folders
```
## Roadmap
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
[docs/gaps.md](docs/gaps.md) for every known shortfall and
@@ -190,6 +201,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
`dxil.dll` is needed ("Parent device is lost" at start, #153). `$env:WGPU_DX12_COMPILER = "dxc"`
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
### macOS
| Build | File | Notes |
@@ -209,6 +224,41 @@ and web backend defaults are unchanged.
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
**Runtime libraries.** The window-system, GPU and file-dialog libraries DeckCraft needs are opened
when the app starts (`dlopen` through `x11-dl`, `xkbcommon-dl`, `ash`, `glutin` and `rfd`) instead
of being linked in, so `ldd` shows none of them: an install that satisfies the linked dependencies
can still fail at start-up with a loader error and no hint of what to install. Only
`libasound.so.2` (slide-show sound) is a hard link-time dependency, and only of the `deckcraft`
GUI — `deckcraft-cli` uses neither sound nor a display. The `.deb` and `.rpm` declare the set in
`packaging/linux/nfpm.yaml`; a tarball install or a musl system has to provide it itself:
```sh
apk add libx11 libxcursor libxrandr libxi libxkbcommon xkeyboard-config \
wayland-libs-client alsa-lib \
vulkan-loader mesa-vulkan-swrast \
mesa-gl mesa-egl mesa-dri-gallium \
xdg-desktop-portal
```
Alpine Linux `v3.22` names above; Void's musl variant uses the same `/lib/ld-musl-x86_64.so.1`
loader and the same libraries as `libX11 libXcursor libXrandr libXi libxkbcommon xkeyboard-config
wayland alsa-lib vulkan-loader mesa xdg-desktop-portal`. There is no musl build among the release
artifacts yet — this is what a build from source on musl needs at run time, and what a future musl
artifact will need (#954).
- `mesa-vulkan-swrast` is the software Vulkan driver: it runs anywhere, VMs and containers
included. On a real GPU install your vendor's driver instead (`mesa-vulkan-intel`,
`mesa-vulkan-ati`, `mesa-vulkan-nouveau`, …).
- Without Vulkan the app falls back to CPU rendering through Mesa's GL drivers. It works, but
slowly — a driver limit, not a DeckCraft bug.
- `xdg-desktop-portal` (with `xdg-desktop-portal-gtk` or `-kde`; `-wlr` has no file chooser, so
on wlroots compositors install `zenity`) is what the Open and Save dialogs talk to; `xkeyboard-config` holds the XKB keyboard data `libxkbcommon` reads.
- fontconfig and freetype are not needed: DeckCraft reads `/usr/share/fonts` itself and embeds its
presentation fonts.
Building from source on musl also needs `build-base pkgconf alsa-lib-dev` (`alsa-lib-dev` is the
ALSA headers).
### FreeBSD
| Build | File |
@@ -271,15 +321,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
generated in code; no Microsoft artwork, fonts, themes or templates are used.
generated in code; no proprietary artwork, fonts, themes or templates are used.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+212 -23
View File
@@ -2,7 +2,10 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -14,8 +17,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
| | Value | Kind |
|---|---|---|
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
| **Feature breadth** | **84%** weighted over 191 PowerPoint features (P0 92%, P1 82%, P2 55%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands at d556ff9 (the registry has grown since; not re-measured) |
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
@@ -45,7 +48,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
| Dimension | Ready | Hours to full | Doc |
|---|---:|---|---|
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
| Features (breadth 84% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
@@ -61,26 +64,26 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|---|---:|---:|---|---|
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Application shell | 81% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Slides and sections | 92% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
| Views | 98% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Masters and themes | 92% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
| Shapes | 91% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Format (fill, line, effects) | 83% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
| Text | 83% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
| Tables | 97% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
| SmartArt | 50% | ~35% | 8–14 | [gaps](docs/gaps.md#smartart-editing-is-shallow) |
| Pictures | 60% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
| Transitions | 100% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
| Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
| Review | 70% | ~45% | 8–14 | [gaps](docs/gaps.md) |
| Draw (ink) | 50% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
| Undo and clipboard | 100% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Files (commands; formats above) | 75% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
## Languages
@@ -111,8 +114,11 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
image paste (#53), editor soak tests. 10–20 h.
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
36–64 h.
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
MicroType Express font subsets and SVG. 36–64 h.
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
4. **Output:** vector PDF, printing. 10–16 h.
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
@@ -128,13 +134,123 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
| 2026-10-18 | About: Contributors and Models get their own fixed-size block below the tab row, so the tab row is no longer squeezed into the credits and the tabs keep answering (#843) |
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 |
| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape |
| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped |
| 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in |
| 2026-10-13 | Codec parsers (#426): H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic, so a hostile number is a format error instead of an overflow |
| 2026-10-13 | WordArt Transform: Shape Format ▸ Text Effects shows the 40 text warps and No Transform; `format.textWarp` applies one to the selected text. Adjust handles are still open |
| 2026-10-13 | PPTX corpus round-trip for decks we author (open → save → reopen, package parts and text). A notes master's header and footer are kept instead of being replaced by an empty notes master |
| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open |
| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open |
| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit |
| 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp |
| 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design |
| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu |
| 2026-10-14 | Review: Previous and Next comment walk every comment in the deck; the Comments pane lists every slide and highlights the one you landed on |
| 2026-10-13 | View › New Window opens a second window on the same presentation. The window has its own slide and shows edits made in the first window. Arrange All is not included |
| 2026-10-14 | File › New starter decks: Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk, original text on our themes (`file.new {template}`, `file.templates`). The large template gallery is still open |
| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides |
| 2026-10-14 | Text: an input method's composition in a table cell is underlined in place, with the caret and candidate window in that cell |
| 2026-10-14 | Edit Photo Album (`insert.photoAlbumEdit`): reopen the album's slides and re-flow them in one undo step, with black and white, rotation, brightness, contrast and an album theme |
| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step |
| 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo |
| 2026-10-14 | Design › Fonts › Customize Fonts… opens Create New Theme Fonts: the open master's scheme name and its heading and body latin faces, applied in one undo |
| 2026-10-14 | Alt Text: Mark as decorative opens checked when the selection already is, so OK no longer clears it |
| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip |
| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time |
| 2026-10-14 | View: the Zoom dialog takes a percent or Fit and applies it on OK. View ▸ Zoom opens it; the arrow still lists the presets |
| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept |
| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by |
| 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial. The presenter window does not move the dot |
| 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show |
| 2026-10-14 | During a show, a typed slide number is shown and Enter goes to that slide |
| 2026-10-14 | A custom show plays only its slides: Show in the Custom Shows dialog, Set Up Slide Show, or a click action |
| 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433 |
| 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom |
| 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) |
| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that |
| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`) |
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) |
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Checklist row Preview in editor re-scored done |
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged |
| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency |
| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) |
| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step |
| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step |
| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
@@ -146,7 +262,80 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls |
| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions |
| 2026-10-13 | trivial | Progress log: embedded fonts |
| 2026-10-13 | trivial | Progress log: codec parsers reject overflowing numeric fields (#426) |
| 2026-10-13 | trivial | Progress log: owned PPTX corpus round-trip; notes-master header and footer |
| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors |
| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end |
| 2026-10-14 | trivial | Progress log: chart trendlines are drawn |
| 2026-10-14 | trivial | Progress log: chart trendlines |
| 2026-10-14 | trivial | Progress log: Lucide icon gallery |
| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
| 2026-10-14 | trivial | Progress log: previous and next comment |
| 2026-10-13 | trivial | Progress log: View › New Window |
| 2026-10-14 | trivial | Progress log: six original starter decks |
| 2026-10-14 | trivial | Progress log: Slide Sorter sections |
| 2026-10-14 | trivial | Progress log: IME underlines in table cells |
| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% |
| 2026-10-14 | trivial | Progress log: Edit Photo Album. Breadth stays 79%, ready ~55% |
| 2026-10-14 | trivial | Progress log: Create New Theme Colors |
| 2026-10-14 | trivial | Progress log: Create New Theme Fonts |
| 2026-10-14 | trivial | Progress log: Alt Text decorative checkbox |
| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection |
| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) |
| 2026-10-14 | trivial | Progress log: Zoom dialog |
| 2026-10-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text |
| 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) |
| 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) |
| 2026-10-14 | trivial | Progress log: Show Media Controls |
| 2026-10-14 | trivial | Progress log: go to slide shows the typed number. Breadth stays 79% |
| 2026-10-14 | trivial | Progress log: custom show playback. Breadth stays 79% |
| 2026-10-14 | trivial | Progress log: presenter timer pause/reset, clock and notes size. Breadth stays 79% |
| 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% |
| 2026-10-13 | trivial | Progress log: command palette Export (#79) |
| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
| 2026-10-14 | trivial | Progress log: Format Background picture and solid options |
| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) |
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) |
| 2026-10-14 | trivial | Progress log: Paragraph dialog |
| 2026-10-14 | trivial | Progress log: Font dialog |
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
+3
View File
@@ -17,3 +17,6 @@ path = "src/main.rs"
deckcraft-engine = { workspace = true }
deckcraft-mcp = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+90
View File
@@ -0,0 +1,90 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--sample", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+186
View File
@@ -0,0 +1,186 @@
pub struct Subcommand {
pub name: &'static str,
pub usage: &'static str,
pub summary: &'static str,
pub details: &'static str,
}
pub const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "info",
usage: "info FILE",
summary: "print a presentation's slides, layouts and theme as JSON",
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
notes, transition), its layouts, theme fonts, sections and slide size.
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
},
Subcommand {
name: "render",
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
summary: "draw slides as PNG images",
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
slide. Prints each image's path and size.
--slide N the slide at index N; indices start at 0, and 0 is the default
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
--edit draw what the editor shows: placeholder prompts and their dashed outlines
--transparent leave the slide background out, so only what is on the slide is drawn
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
},
Subcommand {
name: "render-transition",
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
summary: "draw frames of the transition into a slide as PNG images",
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
--slide N the slide the transition leads into; indices start at 0
--t T,T... the times to draw, comma-separated
--width PX the image width in pixels, twice the slide width in points by default
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
},
Subcommand {
name: "convert",
usage: "convert IN OUT",
summary: "save a presentation in the format OUT's extension names",
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
deckcraft-cli convert deck.deckcraft deck.pptx",
},
Subcommand {
name: "run",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
summary: "run commands on a presentation, then save or export it",
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
slide). --export-params passes file.export options as one JSON object, such as
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
(`deckcraft-cli describe file.export` lists them).
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
deckcraft-cli run \\
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
--cmd 'format.size={\"size\":28}' \\
--save q3.deckcraft
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
parameters.
What the ids don't say:
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
[x, y, width, height].
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
slide.go {\"index\": N} makes slide N current.
- Shape ids are looked up on the current slide only.
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
the id of an inserted shape.",
},
Subcommand {
name: "commands",
usage: "commands [FILTER]",
summary: "list the commands run can use, one per line",
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
deckcraft-cli commands format.
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
},
Subcommand {
name: "describe",
usage: "describe COMMAND",
summary: "explain one command and its parameters",
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
calls it.
deckcraft-cli describe shape.insert",
},
Subcommand {
name: "app",
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
summary: "run a command in the DeckCraft app started with --control",
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
},
Subcommand {
name: "mcp",
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
summary: "serve the commands to an agent over MCP on stdio",
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
--sample opens the sample presentation.
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
app is not confined by them).",
},
Subcommand {
name: "bench",
usage: "bench (FILE | --sample) [--runs N] [--json]",
summary: "time opening, drawing, saving and exporting a presentation",
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
deckcraft-cli bench deck.pptx --runs 10",
},
Subcommand {
name: "links",
usage: "links",
summary: "print the DeckCraft community and project links",
details: "Prints the DeckCraft community and project links.",
},
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
];
pub fn page(name: &str) -> Option<String> {
let name = match name {
"help" | "--help" | "-h" => return Some(overview()),
"--version" | "-V" => "version",
name => name,
};
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
}
pub fn overview() -> String {
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
let list: Vec<String> =
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
format!(
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
{}
A presentation is edited by running commands on it:
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
`deckcraft-cli help run` explains how to write a command.",
list.join("\n")
)
}
+553 -48
View File
@@ -1,19 +1,11 @@
//! Headless DeckCraft.
//!
//! ```text
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
//! deckcraft-cli commands [FILTER] # list commands (JSON)
//! deckcraft-cli describe ID # one command
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
//! deckcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio
//! deckcraft-cli links | --version
//! ```
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod automation_args;
mod help;
use std::process::ExitCode;
use deckcraft_engine::Session;
@@ -32,38 +24,49 @@ macro_rules! outln {
}};
}
const USAGE: &str = "usage: deckcraft-cli info FILE
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
deckcraft-cli convert IN OUT
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
deckcraft-cli commands [FILTER]
deckcraft-cli describe COMMAND
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
deckcraft-cli mcp [--connect PORT] [--sample]
deckcraft-cli links | --version";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest = args.get(1..).unwrap_or(&[]);
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
outln!("{page}");
return ExitCode::SUCCESS;
}
match args.first().map(String::as_str) {
Some("help" | "--help" | "-h") => match rest.first() {
None => {
outln!("{}", help::overview());
ExitCode::SUCCESS
}
Some(name) => match help::page(name) {
Some(page) => {
outln!("{page}");
ExitCode::SUCCESS
}
None => unknown(name),
},
},
Some("--version" | "-V" | "version") => {
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some("info") => report(info(rest)),
Some("bench") => report(bench(rest)),
Some("render") => report(render(rest)),
Some("render-transition") => report(render_transition(rest)),
Some("convert") => report(convert(rest)),
Some("run") => report(run(rest)),
Some("commands") => {
let s = Session::new();
let f = rest.first().map(|x| x.to_lowercase());
let list: Vec<Value> = s
.commands()
.into_iter()
for c in deckcraft_engine::command_specs()
.iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
.map(|c| serde_json::to_value(c).unwrap_or_default())
.collect();
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
{
match c.params.strip_prefix("{}") {
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
None => outln!("{} — {}: {}", c.id, c.label, c.params),
}
}
ExitCode::SUCCESS
}
Some("describe") => report(describe(rest.first().map(String::as_str))),
@@ -74,14 +77,31 @@ fn main() -> ExitCode {
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
ExitCode::SUCCESS
}
_ => {
eprintln!("{USAGE}");
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
ExitCode::FAILURE
}
Some(other) => unknown(other),
None => usage(),
}
}
fn is_help(arg: &str) -> bool {
matches!(arg, "--help" | "-h")
}
/// The help page of subcommand `name`, else the overview.
fn help_page(name: &str) -> String {
help::page(name).unwrap_or_else(help::overview)
}
fn unknown(sub: &str) -> ExitCode {
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
usage()
}
fn usage() -> ExitCode {
eprintln!("{}", help::overview());
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
ExitCode::FAILURE
}
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -97,12 +117,134 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
}
match input {
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
Some(p) => {
let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
warn_if_damaged(p, &opened);
Ok(())
}
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
}
}
/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
let (slides, media) = (count("lostSlides"), count("damagedMedia"));
if slides > 0 {
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
}
if media > 0 {
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
}
}
/// Median and slowest of `runs` timings of `f`, in milliseconds.
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
let mut ms = Vec::with_capacity(runs);
for _ in 0..runs.max(1) {
let t = std::time::Instant::now();
f()?;
ms.push(t.elapsed().as_secs_f64() * 1000.0);
}
ms.sort_by(f64::total_cmp);
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
Ok((median, ms.last().copied().unwrap_or(0.0)))
}
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
fn peak_rss_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
Some(kb / 1024.0)
}
/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to
/// render each slide, to save and to export, and the peak memory, as JSON or a table.
fn bench(args: &[String]) -> Result<(), String> {
use deckcraft_engine::cmd::file::{open_presentation, save_bytes};
let mut input = None;
let mut sample = false;
let mut runs = 5usize;
let mut as_json = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
"--json" => as_json = true,
"--runs" => {
runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")?
}
f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()),
o => return Err(format!("bench: unknown argument `{o}`")),
}
}
if input.is_none() && !sample {
return Err("bench needs a FILE or --sample".into());
}
let mut s = Session::new();
let (name, bytes) = match &input {
Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
None => {
open(&mut s, None, true)?;
let doc = &s.doc().map_err(|e| e.to_string())?.doc;
("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?)
}
};
let e = |x: deckcraft_engine::EngineError| x.to_string();
let doc = open_presentation(&name, &bytes).map_err(e)?;
let slides = doc.slides.len();
let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?;
let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() };
let mut per_slide = vec![];
for i in 0..slides {
per_slide.push(time_ms(runs, || {
deckcraft_engine::render::render_slide(&doc, i, &opts);
Ok(())
})?);
}
let pptx = save_bytes(&doc, "pptx").map_err(e)?;
let mut steps = vec![
("open", open),
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
];
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
let peak = peak_rss_mib();
if as_json {
let v = json!({
"file": name,
"bytes": bytes.len(),
"slides": slides,
"runs": runs,
"steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::<Vec<_>>(),
"renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::<Vec<_>>(),
"peakRssMiB": peak,
});
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
} else {
outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024);
for (k, (m, x)) in &steps {
outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)");
}
if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) {
outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1);
}
if let Some(p) = peak {
outln!(" peak memory {p:>9.1} MiB");
}
}
Ok(())
}
fn info(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{}", help_page("info"));
return Ok(());
}
let path = args.first().ok_or("info needs a FILE")?;
let mut s = Session::new();
open(&mut s, Some(path), false)?;
@@ -118,15 +260,22 @@ fn render(args: &[String]) -> Result<(), String> {
let mut all = false;
let mut scale = 2.0;
let mut edit = false;
let mut transparent = false;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
"--transparent" => transparent = true,
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--all" => all = true,
"--edit" => edit = true,
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
"--help" | "-h" => {
outln!("{}", help_page("render"));
return Ok(());
}
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
other
if input.is_none()
&& !sample
@@ -145,15 +294,12 @@ fn render(args: &[String]) -> Result<(), String> {
open(&mut s, input.as_deref(), sample)?;
let st = s.doc().map_err(|e| e.to_string())?;
let n = st.doc.slides.len();
let list: Vec<usize> = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] };
let list = slide_indices(n, all, slide)?;
if all {
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
}
for i in list {
if i >= n {
return Err(format!("no slide {i} (the deck has {n})"));
}
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({w}×{h})");
@@ -161,13 +307,131 @@ fn render(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses.
/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number.
fn slide_indices(n: usize, all: bool, slide: Option<usize>) -> Result<Vec<usize>, String> {
if all {
return Ok((0..n).collect());
}
let i = slide.unwrap_or(0);
if i >= n {
return Err(missing_slide(i, n));
}
Ok(vec![i])
}
fn missing_slide(i: usize, n: usize) -> String {
match n {
0 => format!("no slide index {i} (this deck has no slides)"),
1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"),
n => {
let last = n - 1;
format!(
"no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)"
)
}
}
}
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
/// the given progress values. With several values, OUT gets `-t<value>` before its extension.
fn render_transition(args: &[String]) -> Result<(), String> {
use deckcraft_engine::render;
let mut input = None;
let mut slide: Option<usize> = None;
let mut ts: Vec<f64> = Vec::new();
let mut width: Option<f64> = None;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--t" => {
let v = it.next().ok_or("--t needs a value")?;
for x in v.split(',') {
let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?;
if !t.is_finite() {
return Err(format!("bad --t value `{x}`"));
}
ts.push(t);
}
}
"--width" => {
width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?)
}
other if input.is_none() => input = Some(other.to_string()),
other => out = Some(other.to_string()),
}
}
let input = input.ok_or("render-transition needs a FILE")?;
let out = out.ok_or("render-transition needs an output path")?;
let i = slide.ok_or("render-transition needs --slide N")?;
if ts.is_empty() {
return Err("render-transition needs --t".into());
}
let paths = frame_paths(&out, &ts)?;
let mut s = Session::new();
open(&mut s, Some(&input), false)?;
let st = s.doc().map_err(|e| e.to_string())?;
let doc = &st.doc;
let n = doc.slides.len();
let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) };
let sw = doc.slide_size.width.max(1.0);
let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64);
let opts = render::RenderOpts { scale: w / sw, ..Default::default() };
let tr = sl.transition.clone().unwrap_or_default();
// Render both slides once and composite every frame from them (morph re-renders per frame).
let prev = i.checked_sub(1);
let cached = (tr.kind != "morph").then(|| {
let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts));
(prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts)
});
let aspect = sw / doc.slide_size.height.max(1.0);
for (t, path) in ts.iter().zip(&paths) {
let img = match &cached {
Some((old, new, parts)) => {
render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect))
}
None => render::render_transition(doc, i, *t, &opts),
};
std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
}
Ok(())
}
/// Output path of each frame. A single frame goes to OUT; several get `-t<value>` (three decimals)
/// before the extension. Values that round to the same name would overwrite each other, so that is an error.
fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
if ts.len() == 1 {
return Ok(vec![out.to_string()]);
}
let p = std::path::Path::new(out);
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
for t in ts {
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
if paths.contains(&path) {
return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}"));
}
paths.push(path);
}
Ok(paths)
}
fn convert(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{}", help_page("convert"));
return Ok(());
}
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
let fmt = output_format(out)?;
let mut s = Session::new();
open(&mut s, Some(input), false)?;
let fmt = deckcraft_engine::cmd::file::format_for_path(out);
let r = if matches!(fmt, "png" | "jpeg") {
s.execute("file.export", &json!({"path": out, "slide": 0}))
} else if fmt == "gif" {
s.execute("file.export", &json!({"path": out}))
} else {
s.execute("file.saveAs", &json!({"path": out}))
};
@@ -176,25 +440,50 @@ fn convert(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the
/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name).
fn output_format(path: &str) -> Result<&'static str, String> {
use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path};
writable_format_for_path(path).ok_or_else(|| {
let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into());
format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", ."))
})
}
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
let mut cmds = vec![];
let mut save = None;
let mut export = None;
let mut export_params = json!({});
let mut print = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--export-params" => {
let j = it.next().ok_or("--export-params needs a JSON object")?;
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
if !export_params.is_object() {
return Err("--export-params must be a JSON object".into());
}
}
"--print" => print = true,
"--help" | "-h" => {
outln!("{}", help_page("run"));
return Ok(());
}
other => return Err(format!("unknown option `{other}`")),
}
}
for p in save.iter().chain(&export) {
output_format(p)?;
}
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
@@ -211,18 +500,76 @@ fn run(args: &[String]) -> Result<(), String> {
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(p) = export {
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
export_params["path"] = json!(p);
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
} else if export_params != json!({}) {
return Err("--export-params needs --export OUT".into());
}
Ok(())
}
fn describe(id: Option<&str>) -> Result<(), String> {
if id.is_some_and(is_help) {
outln!("{}", help_page("describe"));
return Ok(());
}
let id = id.ok_or("describe needs a command id")?;
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
let mut head = format!("{}: {}", c.id, c.label);
if !c.menu.is_empty() {
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
}
if let Some(k) = c.shortcut {
head.push_str(&format!(", shortcut {k}"));
}
outln!("{head}.\n");
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
let parsed = deckcraft_engine::cmd::parse_params(c.params);
match &parsed {
Err(_) => outln!("Parameters: {}", args.trim()),
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
Ok(params) => print_params(params),
}
let params = parsed.unwrap_or_default();
if let Some(acts) = c.acts_on() {
outln!("{acts}");
}
let remark = remark(args);
if !remark.is_empty() {
outln!("Note: {remark}");
}
if !answer.trim().is_empty() {
outln!("Answers with {}.", answer.trim());
}
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
Ok(())
}
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
let name = |p: &deckcraft_engine::cmd::Param| {
let or = if p.or { "or " } else { "" };
let optional = if p.optional { ", optional" } else { "" };
format!("{or}{}{optional}", p.name)
};
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
outln!("Parameters:");
for (k, p) in params.iter().enumerate() {
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
}
let indent = if sets { " " } else { " " };
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
}
}
fn remark(args: &str) -> &str {
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
}
fn app(args: &[String]) -> Result<(), String> {
use deckcraft_mcp::{Backend, Remote, control_addr};
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
@@ -233,6 +580,10 @@ fn app(args: &[String]) -> Result<(), String> {
match a.as_str() {
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
"--help" | "-h" => {
outln!("{}", help_page("app"));
return Ok(());
}
other => rest.push(other.to_string()),
}
}
@@ -253,24 +604,47 @@ fn app(args: &[String]) -> Result<(), String> {
}
fn mcp(args: &[String]) -> Result<(), String> {
use deckcraft_engine::file_access::{self, AutomationRoots};
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut it = args.iter();
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
continue;
}
match a.as_str() {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
"--help" | "-h" => {
outln!("{}", help_page("mcp"));
return Ok(());
}
other => return Err(format!("unknown mcp option `{other}`")),
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
.into());
}
let backend: Box<dyn Backend> = match connect {
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
None => {
let mut h = Headless::new();
// `--sample` is the operator's own choice, made before the roots come into force.
if sample {
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
}
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
}
};
@@ -278,3 +652,134 @@ fn mcp(args: &[String]) -> Result<(), String> {
let stdin = std::io::stdin();
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn render_help_prints_usage_and_writes_nothing() {
assert!(render(&args(&["--help"])).is_ok());
assert!(render(&args(&["-h"])).is_ok());
assert!(!std::path::Path::new("--help").exists());
}
#[test]
fn render_rejects_unknown_flag() {
let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err();
assert_eq!(err, "unknown option `--scael`");
assert!(!std::path::Path::new("--scael").exists());
}
#[test]
fn other_subcommands_accept_help() {
assert!(info(&args(&["--help"])).is_ok());
assert!(convert(&args(&["-h"])).is_ok());
assert!(run(&args(&["--help"])).is_ok());
assert!(describe(Some("--help")).is_ok());
assert!(mcp(&args(&["-h"])).is_ok());
}
#[test]
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
let _ = std::fs::remove_file(&out);
for flag in ["--bogus", "-x"] {
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
let e = render(&args).unwrap_err();
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
assert!(!out.exists(), "{flag}: rendered anyway");
}
}
/// `render --transparent` leaves the slide background out (#784): the corner is transparent.
#[test]
fn render_transparent_leaves_the_background_out() {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
let opaque = dir.join("opaque.png");
render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
assert_eq!(alpha(&opaque), 255);
let clear = dir.join("clear.png");
render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
assert_eq!(alpha(&clear), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
#[test]
fn exports_an_animated_gif() {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let gif = dir.join("sample.gif").to_string_lossy().to_string();
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
let bytes = std::fs::read(&gif).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
convert(&args(&[&deck, &gif2])).unwrap();
let bytes = std::fs::read(&gif2).unwrap();
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
// Bad options are errors, not panics.
let bad = dir.join("bad.gif").to_string_lossy().to_string();
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod slide_index_tests {
use super::{missing_slide, slide_indices};
#[test]
fn slide_numbers_start_at_zero_and_files_at_one() {
assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..]));
assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..]));
assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..]));
assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..]));
let err = slide_indices(8, false, Some(8)).err().unwrap_or_default();
assert!(!err.is_empty(), "index 8 was accepted");
assert!(err.contains("numbered 0 through 7"), "{err}");
assert!(err.contains("slide-01.png"), "{err}");
assert!(err.contains("slide-08.png"), "{err}");
assert!(missing_slide(0, 0).contains("no slides"));
assert!(missing_slide(1, 1).contains("index 0"));
}
}
#[cfg(test)]
mod frame_path_tests {
use super::frame_paths;
#[test]
fn single_frame_uses_out_as_is() {
assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()]));
}
#[test]
fn frames_get_distinct_names() {
let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default();
assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]);
}
#[test]
fn values_rounding_to_one_name_are_rejected() {
assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err());
assert!(frame_paths("f.png", &[0.5, 0.5]).is_err());
}
}
+139
View File
@@ -0,0 +1,139 @@
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
}
fn stdout(o: &Output) -> String {
assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
String::from_utf8_lossy(&o.stderr).into_owned()
}
const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"];
fn words(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn an_unknown_subcommand_is_named_before_the_overview() {
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
let err = stderr(&cli(&args));
assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
}
}
#[test]
fn no_subcommand_shows_the_overview_and_fails() {
assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
}
#[test]
fn help_lists_every_subcommand_with_a_run_example() {
for flag in ["help", "--help", "-h"] {
let out = stdout(&cli(&[flag]));
for sub in SUBCOMMANDS {
assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
}
assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
}
}
#[test]
fn every_subcommand_answers_help_and_does_nothing_else() {
let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for sub in SUBCOMMANDS {
for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
std::fs::remove_dir_all(&dir).unwrap();
for args in [["help", "version"], ["--version", "--help"]] {
assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
}
for args in [["help", "help"], ["help", "--help"]] {
assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
}
}
#[test]
fn run_help_says_what_the_ids_do_not() {
let out = words(&stdout(&cli(&["run", "--help"])));
for fact in [
"COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
"points on a 960×540 slide",
"Slide indices start at 0.",
"Shape ids are looked up on the current slide only.",
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
"format.* acts on the selected text or shapes",
] {
assert!(out.contains(fact), "{fact}: {out}");
}
}
#[test]
fn describe_spells_out_a_command() {
assert_eq!(
stdout(&cli(&["describe", "shape.insert"])),
"shape.insert: Shapes, in Insert ▸ Illustrations.
Parameters:
preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
rect [x,y,w,h] pt
text, optional string
Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
"
);
assert_eq!(
stdout(&cli(&["describe", "format.size"])),
"format.size: Font Size, in Home ▸ Font.
Parameters:
size pt
Acts on the selected text or shapes.
Run it with: deckcraft-cli run --cmd 'format.size={…}'
"
);
assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
assert_eq!(
stdout(&cli(&["describe", "shape.setDefault"])),
"shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
);
}
#[test]
fn describe_shows_alternative_sets_of_parameters() {
assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
let remove = stdout(&cli(&["describe", "animation.remove"]));
assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
assert!(stdout(&cli(&["describe", "shape.move"])).contains(
" either:
dx, optional pt (relative)
dy, optional pt (relative)
ids, optional
or:
x, optional pt (absolute)
y, optional pt (absolute)
ids, optional
"
));
}
#[test]
fn commands_lists_one_line_per_command() {
assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
}
+57
View File
@@ -0,0 +1,57 @@
//! `deckcraft-cli convert` writes the format OUT's extension names, or fails without writing.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn cli(args: &[&Path]) -> Output {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn convert_rejects_output_formats_it_cannot_write() {
let dir = temp_dir("convert-unsupported");
let input = dir.join("in.pptx");
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] {
let target = dir.join(name);
let out = cli(&[Path::new("convert"), &input, &target]);
assert!(!out.status.success(), "{name}: convert should fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("unsupported output format") && err.contains(".pptx"), "{name}: {err}");
assert!(!target.exists(), "{name}: nothing is written");
}
let target = dir.join("out.deckcraft");
let out = cli(&[Path::new("convert"), &input, &target]);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::read(&target).unwrap().starts_with(b"PK\x03\x04"));
let target = dir.join("out.txt");
let out = cli(&[Path::new("convert"), &input, &target]);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(!std::fs::read(&target).unwrap().starts_with(b"PK"), "an outline is text, not a zip");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_rejects_unsupported_save_and_export_paths_before_running() {
let dir = temp_dir("run-unsupported");
for flag in ["--save", "--export"] {
let target = dir.join("deck.odp");
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", flag]).arg(&target).output().unwrap();
assert!(!out.status.success(), "{flag}: run should fail");
assert!(String::from_utf8_lossy(&out.stderr).contains("unsupported output format .odp"), "{flag}");
assert!(!target.exists(), "{flag}: nothing is written");
}
let _ = std::fs::remove_dir_all(&dir);
}
+53
View File
@@ -0,0 +1,53 @@
//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
use std::io::{Cursor, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `deck` with `ppt/slides/slide2.xml` cut off.
fn without_slide2(deck: &[u8]) -> Vec<u8> {
let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
for i in 0..zin.len() {
let mut f = zin.by_index(i).unwrap();
let mut data = vec![];
f.read_to_end(&mut data).unwrap();
if f.name() == "ppt/slides/slide2.xml" {
data = b"<p:sld".to_vec();
}
out.start_file(f.name(), options).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
}
#[test]
fn a_pptx_with_an_unreadable_slide_is_reported_on_stderr() {
let dir = temp_dir("lost-slide");
let good = dir.join("good.pptx");
let made = cli(&["run", "--sample", "--save", good.to_str().unwrap()]);
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let broken = dir.join("broken.pptx");
std::fs::write(&broken, without_slide2(&std::fs::read(&good).unwrap())).unwrap();
let ok = cli(&["info", good.to_str().unwrap()]);
assert!(ok.status.success() && !String::from_utf8_lossy(&ok.stderr).contains("warning"), "{}", String::from_utf8_lossy(&ok.stderr));
let bad = cli(&["info", broken.to_str().unwrap()]);
assert!(bad.status.success(), "{}", String::from_utf8_lossy(&bad.stderr));
let err = String::from_utf8_lossy(&bad.stderr);
assert!(err.contains("warning") && err.contains("1 slide couldn't be read and left out"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+6
View File
@@ -0,0 +1,6 @@
mod cli;
mod convert;
mod damaged;
// 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 mcp_roots;
+116
View File
@@ -0,0 +1,116 @@
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli"))
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
for (i, (name, arguments)) in calls.iter().enumerate() {
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
writeln!(stdin, "{call}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
}
fn text(reply: &Value) -> &str {
reply["result"]["content"][0]["text"].as_str().unwrap()
}
#[test]
fn mcp_automation_roots_confine_the_server() {
let base = tmpdir("roots");
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
std::fs::create_dir_all(&work).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::write(work.join("in.txt"), "Inside text").unwrap();
std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap();
let work_s = work.to_str().unwrap();
let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string();
// Both spellings of the flags; inside works, outside is refused with a readable message.
let read_flag = format!("--automation-read-root={work_s}");
let calls = [
("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})),
("open_presentation", serde_json::json!({"path": secret})),
("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})),
("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})),
];
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
assert!(stderr.contains("files confined"), "{stderr}");
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert!(work.join("saved.deckcraft").exists());
assert_eq!(replies[3]["result"]["isError"], true);
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
assert!(!elsewhere.join("saved.deckcraft").exists());
// A root left out grants none of its access.
let save = [("new_presentation", serde_json::json!({})), calls[2].clone()];
let (_, _, replies) = mcp_session(&[&read_flag], &save);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
// No flags: nothing is confined.
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
assert!(success);
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
let dir = base.to_str().unwrap();
let missing = base.join("missing");
let fails = |args: &[&str], needle: &str| {
let mut full = vec!["mcp"];
full.extend_from_slice(args);
let o = run(&full);
assert!(!o.status.success(), "{args:?}");
let err = String::from_utf8_lossy(&o.stderr).into_owned();
assert!(err.contains(needle), "{args:?}: {err}");
};
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--sample"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root="], "needs a non-empty folder");
// A file is not a folder.
std::fs::write(base.join("f.txt"), "x").unwrap();
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
let _ = std::fs::remove_dir_all(base);
}
+2 -1
View File
@@ -7,7 +7,8 @@
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox` (presentations open, media is inserted);
//! 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;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
+19 -5
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Inbox, Services, SlideApp};
use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
use wasm_bindgen::JsCast as _;
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
@@ -77,16 +77,23 @@ impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
// Capture the document and slide now. The bytes arrive later, after the user may
// have switched slides.
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
ctx.request_repaint();
}
Err(e) => {
log::error!("couldn't read dropped file {name}: {e}");
push_inbox(&inbox, InboxEvent::Cancelled { id });
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -107,7 +114,7 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
@@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
push_inbox(&inbox, InboxEvent::Cancelled { id: request });
ctx.request_repaint();
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
ctx.request_repaint();
});
})),
@@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
}
fn push_inbox(inbox: &Inbox, event: InboxEvent) {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
@@ -168,6 +181,7 @@ fn mime_for(name: &str) -> &'static str {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("gif") => "image/gif",
Some("txt") => "text/plain",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("deckcraft") => "application/zip",
+8
View File
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows.
@@ -29,5 +30,12 @@ arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
cpal = "0.16"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+53
View File
@@ -0,0 +1,53 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e.
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler
//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe
//! main-thread API.
use deckcraft_ui_egui::SlideApp;
use fmv_macos_events::{Event, Inbox, Registration};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
if let Err(e) = app.open_path(p) {
let safe_p = p.replace(['\r', '\n'], " ");
log::error!("{safe_p}: {e}");
app.set_status(format!("Couldn't open {safe_p}: {e}"));
}
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
+18 -1
View File
@@ -1,6 +1,7 @@
//! cpal audio output for media playback: the device pulls mixed samples from the
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
@@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
pub struct CpalOut {
stream: Option<cpal::Stream>,
error: ErrorSlot,
/// Set by the realtime error callback when the device went away: the stream is dead.
dead: Arc<AtomicBool>,
}
impl CpalOut {
@@ -56,7 +59,14 @@ impl AudioOut for CpalOut {
let rate = cfg.sample_rate().0;
let config: cpal::StreamConfig = cfg.clone().into();
let slot = self.error.clone();
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
let dead = Arc::new(AtomicBool::new(false));
self.dead = dead.clone();
let err = move |e: cpal::StreamError| {
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
dead.store(true, Ordering::Relaxed);
}
keep_stream_error(&slot, &e);
};
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
cpal::SampleFormat::I16 => {
@@ -86,6 +96,13 @@ impl AudioOut for CpalOut {
self.stream = None;
self.report();
}
fn failure(&mut self) -> Option<String> {
if !self.dead.swap(false, Ordering::Relaxed) {
return None;
}
Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string()))
}
}
#[cfg(test)]
+477 -30
View File
@@ -1,62 +1,509 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::{Duration, Instant};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Resource bounds for the control server.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
/// Connections served at once; more are told `busy` and closed.
pub max_clients: usize,
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
pub max_line: usize,
/// Requests waiting for the UI at once; more are answered `busy`.
pub queue: usize,
/// How long a connection may go without sending a byte before it is closed.
pub idle_timeout: Duration,
/// How long a reply write may block before the connection is closed.
pub write_timeout: Duration,
/// How long a request waits for the UI's answer.
pub reply_timeout: Duration,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_clients: 16,
// Picture and media inserts carry base64 payloads: room for files of about 190 MB.
max_line: 256 * 1024 * 1024,
queue: 64,
idle_timeout: Duration::from_secs(600),
write_timeout: Duration::from_secs(30),
reply_timeout: Duration::from_secs(60),
}
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
log::info!("control server listening on 127.0.0.1:{port}");
serve_listener(l, Limits::default(), ctx)
}
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
sync_channel::<ControlRequest>(0).1
}
};
log::info!("control server listening on 127.0.0.1:{port}");
}
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
let active = Arc::new(AtomicUsize::new(0));
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
active.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"}));
continue;
}
let slot = Slot(Arc::clone(&active));
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
let spawned = std::thread::Builder::new().name("control-client".into()).spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx, limits);
});
if spawned.is_err() {
log::warn!("control server could not start a client thread");
}
}
});
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
enum Line {
Text(Vec<u8>),
TooLong,
Closed,
}
/// Read one `\n`-terminated line of at most `max` bytes without buffering more than `max + 1`.
fn read_line(r: &mut impl BufRead, max: usize) -> Line {
let mut buf = Vec::new();
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
match r.by_ref().take(limit).read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => Line::Closed,
Ok(_) => {
if buf.last() == Some(&b'\n') {
buf.pop();
Line::Text(buf)
} else if buf.len() > max {
Line::TooLong
} else {
// EOF inside a line: serve what arrived, the next read reports Closed.
Line::Text(buf)
}
}
}
}
/// One request line, parsed.
enum Parsed {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse(bytes: &[u8]) -> Parsed {
let Ok(text) = std::str::from_utf8(bytes) else {
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Parsed::Blank;
}
if is_http_request_line(text) {
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
};
Parsed::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
/// Close after the last reply so the peer can still read it. A peer that had sent another line
/// before the reply arrived -- two lines in one write, which any script does -- leaves input
/// unread in the socket, and closing a socket with unread input resets the connection: the reply
/// already queued for the peer is discarded and it sees only the reset. Sending the FIN first and
/// then draining, briefly and boundedly, makes the close orderly and the reason survive.
fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Duration) {
if out.shutdown(Shutdown::Write).is_err() {
return;
}
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
// for close to twice as long as the deadline says.
const MAX_DRAIN: usize = 64 << 10;
let until = Instant::now() + grace;
let mut sink = [0u8; 4 << 10];
let mut drained = 0;
while drained < MAX_DRAIN {
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || out.set_read_timeout(Some(left)).is_err() {
break;
}
match reader.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(n) => drained += n,
}
}
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let mut reader = BufReader::new(read);
loop {
let line = match read_line(&mut reader, limits.max_line) {
Line::Text(t) => t,
Line::TooLong => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
close_politely(&out, &mut reader, limits.write_timeout);
break;
}
Line::Closed => break,
};
let reply = match parse(&line) {
Parsed::Blank => continue,
Parsed::Reject(e) => {
// The connection closes either way, but the peer is told why first: see
// `close_politely`, without which a peer that had pipelined another line had this
// reply reset away and saw nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
close_politely(&out, &mut reader, limits.write_timeout);
break;
}
Parsed::Request { id, method, params } => {
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let state = Arc::clone(&req.state);
let mut r = match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
match rrx.recv_timeout(limits.reply_timeout) {
Ok(v) => v,
Err(_) => {
// Give up on the request. While it was still queued this makes
// sure it never runs late (#719); if the app had already taken
// it, its outcome is unknown rather than a definite failure.
if ControlRequest::abandon(&state) {
json!({"ok": false, "error": "timeout"})
} else {
json!({"ok": false, "error": "timeout: the app had already started the request, so it may still run; its outcome is unknown"})
}
}
}
}
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
Err(TrySendError::Disconnected(_)) => break,
};
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
if let Err(e) = writeln!(out, "{reply}") {
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
log::debug!("control client stopped reading replies; closing");
}
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
use std::net::SocketAddr;
use std::time::Instant;
fn small() -> Limits {
Limits {
max_clients: 2,
max_line: 64,
queue: 1,
idle_timeout: Duration::from_millis(300),
write_timeout: Duration::from_millis(300),
reply_timeout: Duration::from_millis(300),
}
}
fn server(limits: Limits) -> (SocketAddr, Receiver<ControlRequest>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
(addr, serve_listener(l, limits, egui::Context::default()))
}
fn client(addr: SocketAddr) -> (BufReader<TcpStream>, TcpStream) {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
(BufReader::new(s.try_clone().unwrap()), s)
}
fn reply(r: &mut BufReader<TcpStream>) -> Option<Value> {
let mut s = String::new();
match r.read_line(&mut s) {
Ok(0) | Err(_) => None,
Ok(_) => serde_json::from_str(&s).ok(),
}
}
/// Answer every request with its method name until the server goes away.
fn ui(rx: Receiver<ControlRequest>) {
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
}
#[test]
fn request_round_trip() {
let (addr, rx) = server(small());
ui(rx);
let (mut r, mut w) = client(addr);
writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap();
let v = reply(&mut r).unwrap();
assert_eq!(v["id"], 7);
assert_eq!(v["result"], "ui.inspect");
}
/// Every reply line until the server closes the connection.
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
std::iter::from_fn(|| reply(r)).collect()
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = server(Limits { max_line: 512, ..small() });
let (mut r, mut w) = client(addr);
w.write_all(
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
)
.unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
let (addr, rx) = server(small());
ui(rx);
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let (mut r, mut w) = client(addr);
w.write_all(junk).unwrap();
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
/// line was rejected. Closing with its second line unread resets the connection and discards
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
/// depended on the timing of the two.
#[test]
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
let (addr, rx) = server(small());
for _ in 0..12 {
let (mut r, mut w) = client(addr);
w.write_all(b"hello\n").unwrap();
// A second line, written after the first as a script writes it: it races the close.
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
}
// The line after the rejected one is never dispatched, pipelined or not.
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
}
#[test]
fn oversized_line_closes_connection() {
let (addr, rx) = server(small());
ui(rx);
let (mut r, mut w) = client(addr);
w.write_all(&[b'x'; 200]).unwrap();
let v = reply(&mut r).unwrap();
assert!(v["error"].as_str().unwrap().contains("longer than 64"));
assert!(reply(&mut r).is_none());
}
#[test]
fn idle_connection_is_closed() {
let (addr, rx) = server(small());
ui(rx);
let (mut r, mut w) = client(addr);
w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence
let t = Instant::now();
assert!(reply(&mut r).is_none());
assert!(t.elapsed() < Duration::from_secs(4));
}
#[test]
fn connection_limit_refuses_then_frees_slot() {
let (addr, rx) = server(small());
ui(rx);
let a = client(addr);
let (mut br, mut bw) = client(addr);
// Make sure both are being served before the third arrives.
writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap();
assert!(reply(&mut br).is_some());
let (mut cr, _cw) = client(addr);
let v = reply(&mut cr).unwrap();
assert!(v["error"].as_str().unwrap().starts_with("busy"));
drop(a);
// The freed slot is reusable once the server notices the disconnect.
let deadline = Instant::now() + Duration::from_secs(4);
loop {
let (mut dr, mut dw) = client(addr);
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
break;
}
assert!(Instant::now() < deadline, "slot never freed");
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn blocked_ui_times_out_and_full_queue_is_busy() {
let (addr, rx) = server(small());
// No UI: the first request fills the one-slot queue and times out; the second finds it full.
let (mut ar, mut aw) = client(addr);
let (mut br, mut bw) = client(addr);
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
std::thread::sleep(Duration::from_millis(50));
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
let b = reply(&mut br).unwrap();
assert!(b["error"].as_str().unwrap().starts_with("busy"));
assert_eq!(b["id"], 2);
let a = reply(&mut ar).unwrap();
assert_eq!(a["error"], "timeout");
drop(rx);
}
/// A request that timed out while it was still queued must not run when the window resumes (#719).
#[test]
fn timed_out_request_does_not_run_when_the_window_resumes() {
let (addr, rx) = server(Limits { max_line: 512, ..small() });
let (mut r, mut w) = client(addr);
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
let t = reply(&mut r).unwrap();
assert_eq!(t["error"], "timeout");
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
let ctx = egui::Context::default();
let before = app.session.active().unwrap().doc.slides.len();
let req = rx.try_recv().expect("the timed-out request is still queued");
if req.take() {
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
}
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
drop(rx);
}
/// A request the app had already started when the reply timed out reports an unknown outcome,
/// not a definite failure (#719).
#[test]
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
let (addr, rx) = server(small());
let ui = std::thread::spawn(move || {
let req = rx.recv().expect("the request is queued");
assert!(req.take(), "the app claims the request");
// Still running it when the sender's wait expires.
std::thread::sleep(Duration::from_millis(450));
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
});
let (mut r, mut w) = client(addr);
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
let v = reply(&mut r).unwrap();
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
ui.join().unwrap();
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Which windowing system to start on, on Linux and the BSDs.
//!
//! DeckCraft prefers X11 when `DISPLAY` is set (see `main`). A stale `DISPLAY` (left over from
//! an ended `ssh -X` session or a desktop with Xwayland turned off) used to make start-up fail
//! with winit's "failed to open display" even though a Wayland compositor was running. When a
//! local `DISPLAY` names an X server whose socket isn't there and Wayland is available, start on
//! Wayland instead. With no display at all, say so plainly.
use std::path::Path;
/// The windowing system to ask winit for.
#[derive(Debug, PartialEq, Eq)]
pub enum Choice {
X11,
/// Let winit decide (Wayland when `WAYLAND_DISPLAY` is set).
Default,
/// Neither `DISPLAY` nor `WAYLAND_DISPLAY`: there is nothing to open a window on.
None,
}
/// The X server number of a local `DISPLAY` (`:1`, `:1.0`, `unix:1`); `None` for a remote one
/// (`host:10.0`, as `ssh -X` sets) or one we can't read, which are left to X11 itself.
fn local_display_number(display: &str) -> Option<u32> {
let rest = display.strip_prefix(':').or_else(|| display.strip_prefix("unix:"))?;
rest.split('.').next()?.parse().ok()
}
/// Choose from the environment. `socket_exists` checks an X socket path (the filesystem in
/// [`choose_from_env`], a stub in tests).
pub fn choose(display: Option<&str>, wayland: Option<&str>, force_wayland: bool, socket_exists: impl Fn(&Path) -> bool) -> Choice {
let display = display.filter(|d| !d.is_empty());
let wayland = wayland.is_some_and(|w| !w.is_empty());
match display {
None if wayland => Choice::Default,
None => Choice::None,
Some(_) if force_wayland && wayland => Choice::Default,
Some(d) => match local_display_number(d) {
Some(n) if wayland && !socket_exists(&Path::new("/tmp/.X11-unix").join(format!("X{n}"))) => {
log::warn!("DISPLAY={d} has no X server; starting on Wayland");
Choice::Default
}
_ => Choice::X11,
},
}
}
/// [`choose`] for this process.
pub fn choose_from_env() -> Choice {
let display = std::env::var("DISPLAY").ok();
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_WAYLAND").is_some(), |p| p.exists())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stale_local_display_falls_back_to_wayland() {
let none = |_: &Path| false;
let all = |_: &Path| true;
assert_eq!(choose(Some(":1"), Some("wayland-0"), false, none), Choice::Default);
assert_eq!(choose(Some(":1.0"), Some("wayland-0"), false, none), Choice::Default);
assert_eq!(choose(Some("unix:0"), Some("wayland-0"), false, none), Choice::Default);
// A live X server, no Wayland, or a remote DISPLAY: X11 as before.
assert_eq!(choose(Some(":1"), Some("wayland-0"), false, all), Choice::X11);
assert_eq!(choose(Some(":1"), None, false, none), Choice::X11);
assert_eq!(choose(Some("host:10.0"), Some("wayland-0"), false, none), Choice::X11);
// The socket checked is the display's own.
assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X3")), Choice::X11);
assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X0")), Choice::Default);
}
#[test]
fn wayland_only_forced_and_no_display_at_all() {
let all = |_: &Path| true;
assert_eq!(choose(None, Some("wayland-0"), false, all), Choice::Default);
assert_eq!(choose(Some(""), Some("wayland-0"), false, all), Choice::Default);
assert_eq!(choose(Some(":0"), Some("wayland-0"), true, all), Choice::Default);
// Forcing Wayland without a compositor keeps X11 rather than failing.
assert_eq!(choose(Some(":0"), None, true, all), Choice::X11);
assert_eq!(choose(None, None, false, all), Choice::None);
assert_eq!(choose(Some(""), Some(""), false, all), Choice::None);
}
}
+51 -5
View File
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, Dx12Compiler},
};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
// Auto loads whichever dxcompiler.dll the DLL search path finds; DeckCraft ships
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
// indirect-validation pipeline and loses the device before the first frame
// (issue #153). FXC uses the compiler that is part of Windows.
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
log::info!(
"Windows graphics: backends={:?}, DX12 compiler={:?}",
create.instance_descriptor.backends,
create.instance_descriptor.backend_options.dx12.shader_compiler
);
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
let mut options = NativeOptions::default();
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #153).
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
}
configure(&mut options, None, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn explicit_compiler_override_is_preserved() {
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
let mut options = NativeOptions::default();
configure(&mut options, None, Some(compiler.clone()));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
assert_eq!(actual, expected);
}
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
}
}
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
+36 -19
View File
@@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -389,6 +395,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+244 -53
View File
@@ -1,6 +1,8 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -12,19 +14,32 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_os = "macos")]
mod apple_events;
mod audio;
mod control_server;
#[cfg(all(unix, not(target_os = "macos")))]
mod display;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(not(target_arch = "wasm32"))]
mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
struct App(SlideApp);
struct App(
SlideApp,
/// Files opened from Finder and quit requests (see `apple_events`).
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox,
);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -35,6 +50,16 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
// 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
// `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends
// in a crash report. eframe calls `on_exit` on every way of quitting (⌘Q, the close
// button, `app.quit`), once the unsaved-changes prompt has let the window close, and
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -52,24 +77,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut SlideApp) {
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<deckcraft_ui_egui::UiState>(&bytes)
{
let Some(dir) = config_dir() else { return };
let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir);
if let Some(ui) = ui {
app.ui = ui;
}
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(prefs) = serde_json::from_slice::<deckcraft_engine::Prefs>(&bytes)
{
if let Some(prefs) = prefs {
app.session.prefs = prefs;
}
}
@@ -78,14 +95,8 @@ fn save_prefs(app: &SlideApp) {
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(&p, bytes);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
let _ = std::fs::write(p.with_file_name("prefs.json"), bytes);
}
if let Some(dir) = config_dir() {
deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs);
}
}
@@ -100,6 +111,7 @@ fn services() -> Services {
&["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"],
),
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
"theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]),
_ => d
.add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter("DeckCraft", &["deckcraft"])
@@ -111,14 +123,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(|| {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})),
clipboard_image: Some(Box::new(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
// 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()
@@ -129,6 +136,45 @@ fn services() -> Services {
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
--control PORT JSON control server on 127.0.0.1:PORT
--sample open the sample presentation
--show start the slide show
--help, -h print this help and exit
--version, -V print the version and exit
A PowerPoint Show (.ppsx) given last starts its slide show.
";
enum Startup {
Help,
Version,
Run { control_port: Option<u16>, files: Vec<String>, sample: bool, show: bool },
}
/// `--help` and `--version` leave before the window and the log file.
fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>) -> Startup {
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" | "-V" => return Startup::Version,
"--help" | "-h" => return Startup::Help,
// The process serial number older macOS adds when Finder launches the app.
a if a.starts_with("-psn_") => {}
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show }
}
fn app_icon() -> Option<egui::IconData> {
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
@@ -138,26 +184,21 @@ fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" => {
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
print!("{USAGE}");
return Ok(());
}
Startup::Version => {
println!("deckcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show),
};
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
// The log file lives under the settings directory; opened after the arguments, so `--help` and
// `--version` leave no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
@@ -184,15 +225,32 @@ fn main() -> eframe::Result {
options.viewport = options.viewport.with_icon(icon);
}
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("DECKCRAFT_WAYLAND").is_none() {
let x11 = match display::choose_from_env() {
display::Choice::X11 => true,
display::Choice::Default => false,
display::Choice::None => {
// winit's own error says little to someone who started DeckCraft from a console.
log::error!("no display: DISPLAY and WAYLAND_DISPLAY are not set");
eprintln!("DeckCraft needs a graphical session: neither DISPLAY nor WAYLAND_DISPLAY is set.");
false
}
};
#[cfg(all(unix, not(target_os = "macos")))]
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"DeckCraft",
options,
@@ -216,15 +274,148 @@ fn main() -> eframe::Result {
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample: {e}");
}
for f in files {
if let Err(e) = app.open_path(&f) {
log::error!("{f}: {e}");
}
// A .ppsx opened last starts its slide show (#862).
for (f, e) in app.open_from_system(&files) {
log::error!("{f}: {e}");
}
if show {
app.start_show(0, false);
}
Ok(Box::new(App(app)))
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// #279: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
/// The check runs in a child (this test binary again), since on macOS it ends the process.
#[test]
fn on_exit_ends_the_process_on_macos() {
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
let mut app = App(
SlideApp::new(Session::new(), Services::default()),
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox::default(),
);
println!("calling on_exit");
eframe::App::on_exit(&mut app);
println!("on_exit returned");
return;
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
.env("DECKCRAFT_TEST_ON_EXIT", "1")
.env("DECKCRAFT_NO_PREFS", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{stdout}");
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version));
let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { control_port, files, sample, show } = launch {
assert_eq!(control_port, Some(4242));
assert_eq!(files, ["talk.pptx".to_string()]);
assert!(sample);
assert!(!show);
}
}
#[test]
fn finder_process_serial_number_is_not_a_file() {
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { files, .. } = launch {
assert_eq!(files, ["talk.pptx".to_string()]);
}
}
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_array = false;
for line in plist.lines() {
let line = line.trim();
if line == format!("<key>{key}</key>") {
in_array = true;
continue;
}
if in_array {
for part in line.split("<string>").skip(1) {
if let Some((s, _)) = part.split_once("</string>") {
out.push(s.to_string());
}
}
if line.ends_with("</array>") {
in_array = false;
}
}
}
out
}
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
#[test]
fn every_desktop_offers_the_same_file_types() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
// The native type is declared by its UTI; its extension is in the exported type.
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
mac.sort();
mac.dedup();
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
let names = plist.matches("<key>CFBundleTypeName</key>").count();
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
let mut win: Vec<String> = wxs
.lines()
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
.collect();
win.sort();
let mime_ext = |m: &str| match m {
"application/x-deckcraft" => Some("deckcraft"),
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
_ => None,
};
let mut linux: Vec<String> = desktop
.lines()
.find_map(|l| l.strip_prefix("MimeType="))
.unwrap_or_default()
.split(';')
.filter(|m| !m.is_empty())
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
.collect();
linux.sort();
assert_eq!(mac, win, "macOS and Windows offer different file types");
assert_eq!(mac, linux, "macOS and Linux offer different file types");
}
}
+77
View File
@@ -0,0 +1,77 @@
//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard).
//!
//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain
//! words. A shape with no text is copied as an image by the UI; this module only reads a picture
//! back (Keynote's TIFF) as PNG bytes.
/// HTML currently on the pasteboard, when there is some.
pub fn html() -> Option<String> {
let mut cb = arboard::Clipboard::new().ok()?;
let html = cb.get().html().ok()?;
let html = html.trim();
if html.is_empty() { None } else { Some(html.to_string()) }
}
/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it.
pub fn set_html(html: &str, text: &str) -> bool {
let Ok(mut cb) = arboard::Clipboard::new() else { return false };
cb.set_html(html, Some(text)).is_ok()
}
/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard).
pub fn image_png() -> Option<Vec<u8>> {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let width = u32::try_from(img.width).ok()?;
let height = u32::try_from(img.height).ok()?;
let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
/// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.
/// Restores whatever plain text was there. Other platforms return immediately.
#[test]
fn html_and_a_picture_round_trip_on_macos() {
if !cfg!(target_os = "macos") {
return;
}
let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());
let _restore = Restore(previous);
assert!(super::set_html("<b>DeckCraft</b>", "DeckCraft"), "set html");
let html = super::html().unwrap_or_default();
assert!(html.contains("<b>") || html.contains("<B>"), "{html}");
let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();
assert_eq!(text, "DeckCraft");
let mut cb = arboard::Clipboard::new().expect("pasteboard");
let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) };
cb.set_image(img).expect("set image");
drop(cb);
let png = super::image_png().expect("png");
let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();
let p = decoded.get_pixel(0, 0);
assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}");
}
/// Puts the previous plain text back, including when the test fails halfway.
struct Restore(Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
let Ok(mut cb) = arboard::Clipboard::new() else { return };
match self.0.take() {
Some(t) => {
let _ = cb.set_text(t);
}
None => {
let _ = cb.clear();
}
}
}
}
}
+18
View File
@@ -41,3 +41,21 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.Momentum96]
sync_github_name = true
[people.NikitkaRa]
real_name = "Nikita Rakitin"
[people.RCShoemaker]
real_name = "Ryan C. Shoemaker"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+10
View File
@@ -44,6 +44,15 @@ pub(crate) fn exit_base(effect: &str) -> &str {
"swivelOut" => "swivel",
"bounceOut" => "bounce",
"dissolveOut" => "dissolve",
"blindsOut" => "blinds",
"boxOut" => "box",
"checkerboardOut" => "checkerboard",
"diamondOut" => "diamond",
"peekOut" => "peek",
"stripsOut" => "strips",
"sinkDown" => "rise",
"spinnerOut" => "spinner",
"contract" => "expand",
other => other,
}
}
@@ -69,6 +78,7 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool {
fn uses_default_smoothing(effect: &str) -> bool {
matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath")
|| deckcraft_model::anim::preset_path(effect).is_some()
}
impl EffectSpec {
+266
View File
@@ -0,0 +1,266 @@
//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a
//! link, the next step) and what the show remembers about each slide it has visited.
//!
//! The rules follow PowerPoint, as observed on its slide show:
//! - the topmost visible shape under the pointer decides; a shape without an action on top of one
//! with an action blocks it (the click then counts as a click on the background);
//! - a shape with a triggered animation plays the next step of it per click, and starts over once all
//! its steps have played; clicking it while a step plays completes the step at once;
//! - a shape with a link and a triggered animation follows the link at once. The animation's clock
//! runs only while its slide is shown, so it goes on where it stopped when the show returns;
//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps;
//! - a slide the show returns to (link or arrow keys) looks as it was left.
use deckcraft_model::text::Action;
use deckcraft_model::{Presentation, ShapeId};
use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay};
/// What was played on one slide.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct SlideRun {
/// The show has been on this slide.
pub visited: bool,
/// Main-sequence steps played when the show left the slide.
pub step: usize,
/// Triggered sequences started by clicks.
pub triggers: Vec<TriggerRun>,
/// Steps of triggered sequences started so far (the stamps of [`StepStamp`]).
pub clock: u64,
}
/// Progress of one triggered sequence.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TriggerRun {
pub shape: ShapeId,
/// Clicks whose step has played to its end.
pub done: usize,
/// Seconds the current step has played; `None` when no step is playing.
pub elapsed: Option<f64>,
/// When each step of this run was started, by step.
pub stamps: Vec<u64>,
}
/// One shape of the slide for hit testing, in drawing order (back to front; a group before its
/// children), with whether the pointer is inside its box.
#[derive(Clone, Debug, PartialEq)]
pub struct HitShape {
pub id: ShapeId,
/// Index of the enclosing group in the same list.
pub parent: Option<usize>,
pub is_group: bool,
/// The shape's own action.
pub link: Option<Action>,
pub under_pointer: bool,
}
/// What the pointer is over: a shape that does something when clicked.
#[derive(Clone, Debug, PartialEq)]
pub struct Target {
pub shape: ShapeId,
pub link: Option<Action>,
}
/// What a click did.
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
/// Nothing happened.
None,
/// A triggered animation started or was completed: redraw.
Animated,
/// Navigation inside the show (already applied to the state).
Show(ShowAction),
/// Open this URL outside the show.
Open(String),
/// End the show.
Exit,
}
impl From<LinkJump> for Click {
fn from(j: LinkJump) -> Click {
match j {
LinkJump::Show(a) => Click::Show(a),
LinkJump::Open(u) => Click::Open(u),
LinkJump::Exit => Click::Exit,
LinkJump::None => Click::None,
}
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
impl ShowState {
fn animated(&self, pres: &Presentation) -> bool {
!pres.show.without_animation
}
/// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has
/// played to its end.
fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool {
self.animated(pres) && tl.trigger_steps(shape) > 0
}
/// The playheads of the current slide's triggered sequences at the last tick, for
/// [`Timeline::state_with`].
pub fn trigger_plays(&self) -> Vec<TriggerPlay> {
match self.runs.get(&self.slide) {
Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(),
None => vec![],
}
}
/// When the steps of the current slide's triggered sequences were started.
pub fn trigger_stamps(&self) -> Vec<StepStamp> {
let Some(r) = self.runs.get(&self.slide) else { return vec![] };
r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect()
}
/// Is a triggered animation of the current slide playing (so the picture changes every frame)?
pub fn trigger_playing(&self) -> bool {
self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some()))
}
/// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds
/// played, plus the triggered sequences.
pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState {
tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps())
}
/// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock)
/// and complete the steps that reached their end. [`Click::Animated`] when the picture changed.
pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click {
let dt = match self.last_now {
Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0),
_ => 0.0,
};
if now.is_finite() {
self.last_now = Some(now);
}
let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None };
let mut changed = false;
for t in run.triggers.iter_mut() {
let Some(e) = t.elapsed else { continue };
let e = finite_or_zero(e) + dt;
if e >= tl.trigger_step_duration(t.shape, t.done) {
t.done += 1;
t.elapsed = None;
changed = true;
} else {
t.elapsed = Some(e);
}
}
if changed { Click::Animated } else { Click::None }
}
/// Index of the topmost shape a click lands on: the front-most one under the pointer that is
/// shown. A group without an action does not count; its children and the space inside its box
/// are not it.
pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<usize> {
let shown = |mut i: usize| -> bool {
// A shape is shown when it and the groups around it are.
for _ in 0..=shapes.len() {
let Some(h) = shapes.get(i) else { return false };
if !visible(h.id) {
return false;
}
match h.parent {
Some(p) => i = p,
None => return true,
}
}
false
};
(0..shapes.len())
.rev()
.find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i)))
}
fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool {
h.link.is_some() || self.trigger_live(pres, tl, h.id)
}
/// The shape under the pointer that a click would act on, `None` when a click would be a click
/// on the background. `visible` tells whether a shape is currently shown (not yet entered or
/// already exited shapes cannot be clicked).
pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<Target> {
// The topmost shape, or the group it is in, that has an action. A shape without one blocks
// what lies beneath it.
let mut at = self.top(pres, tl, shapes, visible);
for _ in 0..=shapes.len() {
let h = shapes.get(at?)?;
if self.interactive(pres, tl, h) {
return Some(Target { shape: h.id, link: h.link.clone() });
}
at = h.parent;
}
None
}
/// A mouse click. `shapes` and `visible` describe what is under the pointer (see
/// [`ShowState::target`]); `last_viewed` is the slide shown before this one.
pub fn click(
&mut self,
pres: &Presentation,
tl: &Timeline,
shapes: &[HitShape],
visible: &dyn Fn(ShapeId) -> bool,
now: f64,
last_viewed: Option<usize>,
) -> Click {
self.tick(tl, now);
if self.ended {
return Click::None;
}
let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) };
let mut animated = false;
if self.trigger_live(pres, tl, t.shape) {
let slide = self.slide;
let run = self.runs.entry(slide).or_default();
let at = match run.triggers.iter().position(|r| r.shape == t.shape) {
Some(p) => p,
None => {
run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] });
run.triggers.len() - 1
}
};
let stamp = run.clock.saturating_add(1);
if let Some(r) = run.triggers.get_mut(at) {
if r.elapsed.is_some() {
// A click on a shape whose step is playing completes the step.
r.done += 1;
r.elapsed = None;
} else {
if r.done >= tl.trigger_steps(t.shape) {
// All steps have played: the sequence starts over.
r.done = 0;
r.stamps.clear();
}
r.stamps.push(stamp);
run.clock = stamp;
if tl.trigger_step_duration(t.shape, r.done) > 0.0 {
r.elapsed = Some(0.0);
} else {
r.done += 1;
}
}
animated = true;
}
}
match t.link {
// The link is followed at once, whatever the animation still has to play.
Some(a) => self.follow(pres, &a, last_viewed).into(),
None if animated => Click::Animated,
None => Click::None,
}
}
/// A click that is on no shape with an action: next animation step, then next slide, unless the
/// slide has "advance on click" off, which leaves only the animation steps.
fn background_click(&mut self, pres: &Presentation) -> Click {
let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click);
let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide);
if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None }
}
}
+15 -4
View File
@@ -5,7 +5,8 @@
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was
//! played on each visited slide, and what a click does ([`ShowState::click`]).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
//! negative times) and never panics.
@@ -14,24 +15,34 @@
mod easing;
mod effects;
mod interact;
mod mesh;
mod morph;
mod path;
mod show;
mod timeline;
mod transition;
mod transition_fx;
mod transition_geom;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_interact;
#[cfg(test)]
mod transition_tests;
pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use interact::{Click, HitShape, Target};
pub use mesh::{MeshVertex, layer_mesh};
pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
pub use transition::{Layer, Source, transition_layers};
pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
///
+139
View File
@@ -0,0 +1,139 @@
//! Triangle meshes for transition layers.
//!
//! A [`Layer`] is a textured quad. Drawn as two triangles, a trapezoid (a slide seen in
//! perspective) bends its texture along the diagonal, because GPUs and simple rasterisers
//! interpolate texture coordinates linearly in screen space. [`layer_mesh`] splits such quads into a
//! grid whose vertices follow the projective map from the source rectangle to the quad, so the
//! picture looks like a flat card turned in 3-D. Rectangles and parallelograms stay two triangles,
//! wedges one: the per-frame cost stays a handful of vertices per layer.
use crate::transition::Layer;
/// One mesh vertex: position in slide-unit space (0..1, y down), texture coordinate (0..1) and
/// opacity (the layer's alpha times its corner fade, interpolated).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshVertex {
pub pos: [f64; 2],
pub uv: [f64; 2],
pub a: f64,
}
/// Grid cells per side for quads that need a projective subdivision.
const GRID: usize = 8;
/// The triangles of a layer: vertices and index triples into them.
pub fn layer_mesh(l: &Layer) -> (Vec<MeshVertex>, Vec<[u32; 3]>) {
let q = l.quad;
let uv = l.uv;
let fa = l.fade.map(|f| f * l.alpha);
// A wedge (triangle): the 4th corner repeats the 3rd.
if q[3] == q[2] && uv[3] == uv[2] {
let v = (0..3).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
return (v, vec![[0, 1, 2]]);
}
let affine = {
// Parallelogram: opposite edges equal.
let e1 = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
let e2 = [q[2][0] - q[3][0], q[2][1] - q[3][1]];
(e1[0] - e2[0]).abs() < 1e-9 && (e1[1] - e2[1]).abs() < 1e-9
};
let h = if affine { None } else { square_to_quad(q) };
let Some(h) = h else {
let v = (0..4).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
return (v, vec![[0, 1, 2], [0, 2, 3]]);
};
// Small quads (tiles, shards) bend their texture too little to matter: fewer cells.
let span = q.iter().flat_map(|p| q.iter().map(move |r| (p[0] - r[0]).abs().max((p[1] - r[1]).abs()))).fold(0.0, f64::max);
let n = ((span * 2.0 * GRID as f64).ceil() as usize).clamp(1, GRID);
let mut v = Vec::with_capacity((n + 1) * (n + 1));
for j in 0..=n {
let b = j as f64 / n as f64;
for i in 0..=n {
let a = i as f64 / n as f64;
// Texture: bilinear in (a, b) over the four uv corners (exact for rectangles).
let top = lerp2(uv[0], uv[1], a);
let bot = lerp2(uv[3], uv[2], a);
let t = lerp2(top, bot, b);
let at = fa[0] + (fa[1] - fa[0]) * a;
let ab = fa[3] + (fa[2] - fa[3]) * a;
v.push(MeshVertex { pos: apply(&h, a, b), uv: t, a: at + (ab - at) * b });
}
}
let mut tris = Vec::with_capacity(n * n * 2);
let w = (n + 1) as u32;
for j in 0..n as u32 {
for i in 0..n as u32 {
let p = j * w + i;
tris.push([p, p + 1, p + w + 1]);
tris.push([p, p + w + 1, p + w]);
}
}
(v, tris)
}
fn lerp2(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
/// Projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`'s tl, tr, br,
/// bl (Heckbert's square-to-quad). `None` when the quad is degenerate.
fn square_to_quad(q: [[f64; 2]; 4]) -> Option<[f64; 8]> {
let [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q;
let sx = x0 - x1 + x2 - x3;
let sy = y0 - y1 + y2 - y3;
let (dx1, dx2, dy1, dy2) = (x1 - x2, x3 - x2, y1 - y2, y3 - y2);
let den = dx1 * dy2 - dx2 * dy1;
if !den.is_finite() || den.abs() < 1e-12 {
return None;
}
let g = (sx * dy2 - dx2 * sy) / den;
let h = (dx1 * sy - sx * dy1) / den;
let m = [x1 - x0 + g * x1, x3 - x0 + h * x3, x0, y1 - y0 + g * y1, y3 - y0 + h * y3, y0, g, h];
m.iter().all(|v| v.is_finite()).then_some(m)
}
fn apply(m: &[f64; 8], u: f64, v: f64) -> [f64; 2] {
let w = m[6] * u + m[7] * v + 1.0;
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
[(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transition::{Layer, Source};
#[test]
fn rect_is_two_triangles() {
let (v, t) = layer_mesh(&Layer::full(Source::New, 1.0));
assert_eq!((v.len(), t.len()), (4, 2));
}
#[test]
fn trapezoid_is_subdivided_projectively() {
// A card turned about its left edge: the right edge is shorter.
let q = [[0.0, 0.0], [0.6, 0.2], [0.6, 0.8], [0.0, 1.0]];
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
let (v, t) = layer_mesh(&l);
assert_eq!(v.len(), (GRID + 1) * (GRID + 1));
assert_eq!(t.len(), GRID * GRID * 2);
// A small tile in perspective needs no more than a couple of triangles.
let small = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], [[0.5, 0.5], [0.52, 0.505], [0.52, 0.515], [0.5, 0.52]], 1.0);
assert!(layer_mesh(&small).1.len() <= 2);
// Corners land on the quad's corners.
assert_eq!(v[0].pos, q[0]);
let last = v[v.len() - 1].pos;
assert!((last[0] - 0.6).abs() < 1e-9 && (last[1] - 0.8).abs() < 1e-9);
// The texture's middle column sits nearer the far (short) edge than halfway: foreshortening.
let mid = v[GRID / 2].pos[0];
assert!(mid > 0.3, "{mid}");
}
#[test]
fn degenerate_quads_do_not_blow_up() {
let q = [[0.5, 0.5]; 4];
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
let (v, _) = layer_mesh(&l);
assert!(v.iter().all(|p| p.pos[0].is_finite() && p.pos[1].is_finite()));
}
}
+71 -5
View File
@@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String {
/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by
/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same
/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole).
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
/// once; top-level shapes only (groups move whole).
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
@@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty() == text_of(y).trim().is_empty()
&& text_of(x).trim().is_empty()
&& text_of(y).trim().is_empty()
},
];
for pass in passes {
@@ -173,9 +176,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool {
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
}
/// The content of a shape apart from what Morph interpolates, with a picture's crop left out.
fn uncropped(s: &Shape) -> Shape {
let mut c = content(s);
if let ShapeKind::Picture { fill } = &mut c.kind {
fill.crop = [0.0; 4];
}
c
}
fn crop_of(s: &Shape) -> [f64; 4] {
match &s.kind {
ShapeKind::Picture { fill } => fill.crop,
_ => [0.0; 4],
}
}
/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph.
fn recropped(a: &Shape, b: &Shape) -> bool {
matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b)
}
/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box
/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales
/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back
/// to blending the crops when a crop leaves nothing visible.
pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] {
// Along one axis: (box start, box size, crop before, crop after) → the crops at `t`.
let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| {
let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb);
if va <= 1e-6 || vb <= 1e-6 {
return (lerp(la, lb, t), lerp(ha, hb, t));
}
// The whole image: its extent and start on either slide.
let (ea, eb) = (sa / va, sb / vb);
let e = lerp(ea, eb, t);
if !e.is_finite() || e.abs() <= 1e-9 {
return (lerp(la, lb, t), lerp(ha, hb, t));
}
let o = lerp(pa - la * ea, pb - lb * eb, t);
let lo = (p - o) / e;
(lo, 1.0 - lo - s / e)
};
let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w));
let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h));
let c = [l, tp, r, bt];
if c.iter().all(|v| v.is_finite()) { c } else { cb }
}
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only
/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
/// otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
@@ -223,8 +275,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
(_, xb) => xb,
};
let reshape = reshaped(sa, sb);
if reshape || content(sa) == content(sb) {
let recrop = recropped(sa, sb);
if reshape || recrop || content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
if recrop && let ShapeKind::Picture { fill } = &mut m.kind {
fill.crop = match (xa, xb, x) {
(Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s),
_ => {
let (ca, cb) = (crop_of(sa), crop_of(sb));
[lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)]
}
};
}
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
@@ -237,7 +299,11 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
if by_text
// The same text in shapes that differ otherwise (a title slide's centred title and a
// content slide's title: other placeholder, size, alignment) moves and resizes word
// by word even with the Objects option, as PowerPoint does, instead of cross-fading.
let same_text = text_of(sa).trim() == text_of(sb).trim();
if (by_text || same_text)
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
+23 -2
View File
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
} {
i += 2;
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
while cs.get(i).is_some_and(char::is_ascii_digit) {
i += 1;
}
break;
@@ -288,6 +288,27 @@ pub fn default_path(effect: &str, option: &str) -> String {
}
},
"loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(),
_ => String::new(),
other => deckcraft_model::anim::preset_path(other).unwrap_or_default(),
}
}
#[cfg(test)]
mod preset_path_tests {
use super::*;
#[test]
fn every_extra_motion_path_preset_starts_at_its_shape_and_moves() {
use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path};
let extra: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && preset_path(a.0).is_some()).collect();
assert!(extra.len() >= 30);
for a in extra {
assert_eq!(default_path(a.0, ""), preset_path(a.0).unwrap_or_default());
let mp = MotionPath::parse(&default_path(a.0, ""));
let (x, y) = mp.at(0.0);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "{} starts at its shape", a.0);
let far = mp.points.iter().map(|p| p.0.abs() + p.1.abs()).fold(0.0, f64::max);
assert!(far > 0.05, "{} moves", a.0);
assert!(mp.points.iter().all(|p| p.0.abs() < 0.5 && p.1.abs() < 0.5), "{} stays near its shape", a.0);
}
}
}
+157 -23
View File
@@ -1,10 +1,13 @@
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
//! and automatic advance.
use std::collections::BTreeMap;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action;
use deckcraft_model::text::{Action, Hyperlink};
use crate::Timeline;
use crate::interact::SlideRun;
/// What the UI should do after a navigation call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -52,8 +55,10 @@ pub enum LinkJump {
None,
}
/// Where a slide show is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
/// Where a slide show is. Besides the current position it remembers what was played on every slide
/// the show has visited (main steps, triggered sequences), so a slide reached again by a link or
/// the arrow keys looks the way it was left, as in PowerPoint.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct ShowState {
/// Index into `presentation.slides`.
pub slide: usize,
@@ -63,6 +68,19 @@ pub struct ShowState {
pub playing: bool,
/// On the end screen.
pub ended: bool,
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
active: bool,
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
pub(crate) last_now: Option<f64>,
/// What was played on each visited slide (index into `presentation.slides`).
pub(crate) runs: BTreeMap<usize, SlideRun>,
/// One-shot visit order (Custom Show from the dialog or a click). `None` uses [`show_order`].
order: Option<Vec<usize>>,
/// Name of the custom show being played, when there is one.
custom: Option<String>,
/// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes
/// back to the zoom slide, fully built.
pub zoom: Option<(usize, usize)>,
}
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
@@ -83,6 +101,18 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect()
}
/// Visible slides of custom show `name`, in that show's order.
fn custom_indices(pres: &Presentation, name: &str) -> Option<Vec<usize>> {
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
let order: Vec<usize> = cs
.slides
.iter()
.filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
.filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
.collect();
if order.is_empty() { None } else { Some(order) }
}
fn looping(pres: &Presentation) -> bool {
pres.show.loop_until_esc || pres.show.show_type == "kiosk"
}
@@ -104,9 +134,48 @@ impl ShowState {
} else {
s.ended = true;
}
if let Some(name) = pres.show.custom_show.clone() {
s.custom = Some(name);
}
s
}
/// Play custom show `name` from its first slide, without changing Set Up Slide Show.
/// `None` when that show is missing or has no visible slides.
pub fn play_custom(pres: &Presentation, name: &str) -> Option<ShowState> {
let order = custom_indices(pres, name)?;
let &first = order.first()?;
let mut s = ShowState::start_at(pres, first);
s.order = Some(order);
s.custom = Some(name.to_string());
Some(s)
}
/// Name of the custom show this playback is limited to.
pub fn custom_name(&self) -> Option<&str> {
self.custom.as_deref().filter(|s| !s.is_empty())
}
/// The slide that follows the current one in this show.
pub fn peek_next(&self, pres: &Presentation) -> Option<usize> {
self.next_index(pres)
}
pub fn first_index(&self, pres: &Presentation) -> Option<usize> {
self.visit(pres).first().copied()
}
pub fn last_index(&self, pres: &Presentation) -> Option<usize> {
self.visit(pres).last().copied()
}
fn visit(&self, pres: &Presentation) -> Vec<usize> {
match &self.order {
Some(order) => order.iter().copied().filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect(),
None => show_order(pres),
}
}
/// The timeline of slide `i`.
pub fn timeline(pres: &Presentation, i: usize) -> Timeline {
match pres.slides.get(i) {
@@ -120,15 +189,41 @@ impl ShowState {
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
}
/// Save what was played on the current slide before the show moves away from it. A main step
/// that is playing is taken to its end. Triggered animations keep their place: their clock only
/// runs while the slide is shown, and they go on from there when the show comes back.
fn leave(&mut self, pres: &Presentation) {
if !self.active {
return;
}
let done = if self.playing { self.step + 1 } else { self.step };
let step = done.min(ShowState::steps(pres, self.slide));
self.runs.entry(self.slide).or_default().step = step;
self.playing = false;
}
/// Show slide `i`: as it was left when the show has been there before, otherwise from the start.
fn enter(&mut self, pres: &Presentation, i: usize) {
self.leave(pres);
self.active = true;
self.slide = i;
self.step = 0;
self.ended = false;
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
// Time spent elsewhere never counts for this slide's triggered animations.
self.last_now = None;
let steps = ShowState::steps(pres, i);
let run = self.runs.entry(i).or_default();
if run.visited {
self.step = run.step.min(steps);
self.playing = false;
} else {
run.visited = true;
self.step = 0;
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
}
}
fn next_index(&self, pres: &Presentation) -> Option<usize> {
let order = show_order(pres);
let order = self.visit(pres);
match order.iter().position(|&i| i == self.slide) {
Some(p) => order.get(p + 1).copied(),
None => order.iter().copied().find(|&i| i > self.slide),
@@ -136,7 +231,7 @@ impl ShowState {
}
fn prev_index(&self, pres: &Presentation) -> Option<usize> {
let order = show_order(pres);
let order = self.visit(pres);
match order.iter().position(|&i| i == self.slide) {
Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()),
None => order.iter().copied().rev().find(|&i| i < self.slide),
@@ -160,12 +255,22 @@ impl ShowState {
self.playing = true;
return ShowAction::PlayStep(self.step);
}
if let Some((back, until)) = self.zoom
&& until == self.slide
&& back < pres.slides.len()
{
self.zoom = None;
self.enter(pres, back);
self.step = ShowState::steps(pres, back);
self.playing = false;
return ShowAction::Slide(back);
}
match self.next_index(pres) {
Some(i) => {
self.enter(pres, i);
ShowAction::Slide(i)
}
None if looping(pres) => match show_order(pres).first() {
None if looping(pres) => match self.visit(pres).first() {
Some(&i) => {
self.enter(pres, i);
ShowAction::Slide(i)
@@ -194,7 +299,8 @@ impl ShowState {
ShowAction::FinishStep(n)
}
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
/// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
/// first step (With/After Previous) is part of the slide appearing, so it is never undone.
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
if self.ended {
self.ended = false;
@@ -205,24 +311,32 @@ impl ShowState {
self.playing = false;
return ShowAction::Slide(self.slide);
}
if self.playing {
// #844: undoing the automatic first step left the slide as before it appeared, and the show
// played the step again at once, so Back never got past the slide.
let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
if let Some(k) = undo
&& !auto_first
{
self.playing = false;
return ShowAction::StepBack(self.step);
}
if self.step > 0 {
self.step -= 1;
return ShowAction::StepBack(self.step);
self.step = k;
return ShowAction::StepBack(k);
}
let target = match self.prev_index(pres) {
Some(i) => Some(i),
None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide),
None if looping(pres) => self.visit(pres).last().copied().filter(|&i| i != self.slide),
None => None,
};
match target {
Some(i) => {
self.slide = i;
self.step = ShowState::steps(pres, i);
self.playing = false;
// A slide the show has been to looks as it was left; one it has not been to is
// shown fully built.
let seen = self.runs.get(&i).is_some_and(|r| r.visited);
self.enter(pres, i);
if !seen {
self.step = ShowState::steps(pres, i);
self.playing = false;
}
ShowAction::Slide(i)
}
None => ShowAction::None,
@@ -234,6 +348,7 @@ impl ShowState {
if i >= pres.slides.len() {
return ShowAction::None;
}
self.zoom = None;
self.enter(pres, i);
ShowAction::Slide(i)
}
@@ -247,11 +362,18 @@ impl ShowState {
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
Action::NextSlide => self.next_index(pres),
Action::PreviousSlide => self.prev_index(pres),
Action::FirstSlide => show_order(pres).first().copied(),
Action::LastSlide => show_order(pres).last().copied(),
Action::FirstSlide => self.visit(pres).first().copied(),
Action::LastSlide => self.visit(pres).last().copied(),
Action::LastViewed => last_viewed,
// Custom shows, programs and media actions aren't followed (yet).
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
Action::CustomShow { name, .. } => {
let Some(order) = custom_indices(pres, name) else { return LinkJump::None };
let Some(&i) = order.first() else { return LinkJump::None };
self.order = Some(order);
self.custom = Some(name.clone());
return LinkJump::Show(self.goto(pres, i));
}
// Programs and media actions aren't followed.
Action::Program { .. } | Action::PlayMedia => None,
};
match target {
Some(i) => LinkJump::Show(self.goto(pres, i)),
@@ -259,6 +381,18 @@ impl ShowState {
}
}
/// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]).
pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option<usize>) -> LinkJump {
let from = self.slide;
let jump = self.follow(pres, &link.action, last_viewed);
if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id))
&& matches!(jump, LinkJump::Show(ShowAction::Slide(_)))
{
self.zoom = Some((from, until));
}
jump
}
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
/// show uses timings.
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
+293
View File
@@ -344,6 +344,36 @@ fn empty_slide() {
assert_eq!(tl.locate(5.0), (0, 0.0));
}
#[test]
fn start_of_seeks_the_play_all_clock_to_an_effect() {
let mut late = anim(4, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
late.delay_ms = 250;
let mut triggered = anim(4, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
triggered.trigger = Some(ShapeId(1));
let tl = Timeline::new(
&slide_with(vec![
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick),
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
late,
triggered,
]),
W,
H,
);
assert_eq!(tl.start_of(0), Some(0.0));
assert_eq!(tl.start_of(2), Some(2.0));
assert_eq!(tl.start_of(3), Some(2.25));
assert_eq!(tl.start_of(4), None, "a triggered effect is not on the play-all clock");
assert_eq!(tl.start_of(9), None);
// Playing from the third effect: what came before has played, the rest has not.
let at = tl.start_of(2).unwrap();
assert!(tl.state_at(ShapeId(1), None, at).visible, "entered before");
assert!(!tl.state_at(ShapeId(2), None, at).visible, "exited before");
assert!(!tl.state_at(ShapeId(3), None, at).visible, "not entered yet");
assert!(!tl.state_at(ShapeId(4), None, at).visible, "not entered yet");
}
#[test]
fn locate_play_all_clock() {
let tl = Timeline::new(
@@ -529,6 +559,61 @@ fn morph_pairs_by_name_text_and_geometry() {
assert!(morph_pairs(&Slide::default(), &b).is_empty());
}
#[test]
fn morph_pairs_leaves_unrelated_text_boxes_alone() {
// A text box that leaves and an unrelated one that arrives fade in place (#119).
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) };
a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")];
b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")];
assert!(morph_pairs(&a, &b).is_empty());
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect();
assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]);
// Empty shapes of one kind and geometry still pair, and a named text box keeps its partner.
a.shapes.push(shape(3, "Rect A"));
b.shapes.push(shape(4, "Rect B"));
b.shapes[0].name = "TextBox 1".into();
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]);
}
#[test]
fn morph_crop_moves_the_image_linearly() {
let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0);
// Same box, the crop pans from the left half to the right half of a 200-wide image.
let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
// The box shrinks over an image that stays put: only the right crop grows.
let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5);
assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
// Nothing visible on one side: the crops blend.
let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}");
}
#[test]
fn morph_frame_morphs_a_picture_crop() {
// A picture recropped on the next slide stays one picture whose crop morphs (#118).
let p = deckcraft_model::defaults::new_presentation(None);
let pic = |id, crop| Shape {
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } },
..shape(id, "Picture 1")
};
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])];
b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])];
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert_eq!(f.shapes.len(), 1);
assert!(f.opacity.is_empty());
let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") };
let want = 0.5 * f.mix;
assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop);
assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind);
}
#[test]
fn morph_xfrm_interpolates() {
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
@@ -702,6 +787,37 @@ fn show_custom_show_and_range() {
assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]);
}
#[test]
fn playing_a_custom_show_stays_on_its_slides() {
use deckcraft_model::text::Action;
let mut p = pres(5);
p.custom_shows = vec![CustomShow { name: "Ends".into(), slides: vec![SlideId(1000), SlideId(1004)] }];
assert!(p.show.custom_show.is_none());
let mut s = ShowState::play_custom(&p, "Ends").expect("the show exists");
assert_eq!(s.custom_name(), Some("Ends"));
assert_eq!(s.slide, 0);
assert_eq!(s.peek_next(&p), Some(4));
assert_eq!(s.next(&p), ShowAction::Slide(4));
assert_eq!(s.next(&p), ShowAction::End);
assert_eq!(s.prev(&p), ShowAction::Slide(4));
assert_eq!(s.prev(&p), ShowAction::Slide(0));
assert!(ShowState::play_custom(&p, "Missing").is_none());
assert!(p.show.custom_show.is_none(), "one-shot playback does not change Set Up");
Arc::make_mut(&mut p.slides[4]).hidden = true;
let only = ShowState::play_custom(&p, "Ends").expect("the visible slide remains");
assert_eq!(only.first_index(&p), Some(0));
assert_eq!(only.last_index(&p), Some(0));
let mut s = ShowState::new(&p);
p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() });
assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into(), returns: true }, None), LinkJump::Show(ShowAction::Slide(0)));
assert_eq!(s.custom_name(), Some("Ends"));
assert_eq!(s.next(&p), ShowAction::Slide(4));
assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into(), returns: true }, None), LinkJump::None);
assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide");
}
#[test]
fn show_steps_through_builds() {
let mut p = pres(2);
@@ -722,6 +838,30 @@ fn show_steps_through_builds() {
assert_eq!(s.step, 1);
}
#[test]
fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
// #844: undoing the automatic first step left the slide as before it appeared, and the show
// played that step again at once, so Back never reached the slide before.
for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
let mut p = pres(2);
Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
Arc::make_mut(&mut p.slides[1]).animations =
vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
let mut s = ShowState::new(&p);
assert_eq!(s.next(&p), ShowAction::Slide(1));
assert!(s.playing, "{start:?}");
// While the automatic step plays.
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
assert_eq!(s.next(&p), ShowAction::Slide(1));
// Once it has played, the click step is undone first, then Back leaves the slide.
assert_eq!(s.next(&p), ShowAction::PlayStep(1));
assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
assert_eq!((s.step, s.playing), (0, false));
}
}
#[test]
fn show_auto_advance() {
let mut p = pres(2);
@@ -789,3 +929,156 @@ fn show_follows_links() {
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
assert_eq!(s.slide, 3);
}
/// #420: late in the Honeycomb every cell has settled, so the incoming hexagons must cover the
/// whole slide — the old row count stopped at ~75% of a 16:9 slide and left the bottom black.
#[test]
fn honeycomb_cells_reach_the_bottom_before_the_slide_ends() {
let sign = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1]);
let inside = |p: [f64; 2], t: [[f64; 2]; 3]| {
// The dark seams between cells are the effect: at t=0.99 they are ~0.012% of the width
// open, so a 1% pad forgives them and still fails genuinely uncovered ground.
let c = [(t[0][0] + t[1][0] + t[2][0]) / 3.0, (t[0][1] + t[1][1] + t[2][1]) / 3.0];
let t = t.map(|v| [c[0] + (v[0] - c[0]) * 1.01, c[1] + (v[1] - c[1]) * 1.01]);
let (d1, d2, d3) = (sign(p, t[0], t[1]), sign(p, t[1], t[2]), sign(p, t[2], t[0]));
!((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
};
for aspect in [16.0 / 9.0, 3.0 / 2.0, 4.0 / 3.0, 11.0 / 8.5, 1.0] {
// t = 0.99: every cell is settled and the seams are nearly closed.
let ls = transition_frame("honeycomb", "", 0.99, aspect);
let tris: Vec<[[f64; 2]; 3]> =
ls.iter().filter(|l| l.source == Source::New && l.alpha >= 1.0).map(|l| [l.quad[0], l.quad[1], l.quad[2]]).collect();
assert!(!tris.is_empty());
let mut sy = 0.0;
while sy <= 1.0 {
for sx in [0.0, 0.2, 0.4, 0.6, 0.8, 0.99, 1.0] {
let p = [sx, sy];
assert!(tris.iter().any(|t| inside(p, *t)), "honeycomb aspect {aspect} t=0.99: {sx},{sy} is uncovered (no settled cell reaches it)");
}
sy += 0.02;
}
}
}
/// #147: exits PowerPoint plays downward leave downward here too.
#[test]
fn sink_down_and_peek_out_to_bottom_move_down() {
for (effect, option) in [("sinkDown", ""), ("peekOut", "b"), ("floatOut", "d")] {
let mut a = anim(1, AnimClass::Exit, effect, AnimStart::OnClick);
a.option = option.into();
let tl = Timeline::new(&slide_with(vec![a]), W, H);
let s = tl.state(ShapeId(1), None, 0, 0.5);
assert!(s.offset_y > 0.0, "{effect} {option}: {s:?}");
}
}
#[test]
fn trigger_sequences_keep_first_appearance_order_when_interleaved() {
let mut anims = vec![];
for (i, tr) in [7u32, 5, 7, 0, 9, 5].iter().enumerate() {
let mut a = anim(1 + (i as u32 % 4), AnimClass::Entrance, "fade", AnimStart::OnClick);
a.trigger = (*tr != 0).then_some(ShapeId(*tr));
anims.push(a);
}
let tl = Timeline::new(&slide_with(anims), W, H);
assert_eq!(tl.trigger_shapes(), vec![ShapeId(7), ShapeId(5), ShapeId(9)]);
assert_eq!(tl.trigger_steps(ShapeId(7)), 2);
assert_eq!(tl.trigger_steps(ShapeId(5)), 2);
assert_eq!(tl.trigger_steps(ShapeId(9)), 1);
assert_eq!(tl.steps(), 1);
}
#[test]
fn many_distinct_triggers_past_the_effect_cap_build_in_order() {
// One sequence per animation: grouping used to rescan every animation per trigger
// (~1.2e10 steps here), now it is one pass, and nothing past the cap is looked at.
let n = 110_000u32;
let anims: Vec<Animation> = (0..n)
.map(|i| {
let mut a = anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick);
a.trigger = Some(ShapeId(1000 + i));
a
})
.collect();
let tl = Timeline::new(&slide_with(anims), W, H);
assert_eq!(tl.effects().len(), 100_000);
let shapes = tl.trigger_shapes();
assert_eq!(shapes.len(), 100_000);
assert!(shapes.iter().enumerate().all(|(i, s)| *s == ShapeId(1000 + i as u32)));
}
/// #316: the same text in two shapes that differ otherwise (a title slide's big centred title,
/// a content slide's title) morphs word by word with the default Objects option, instead of
/// the old box cross-fading into the new one.
#[test]
fn same_text_morphs_even_by_objects() {
let p = deckcraft_model::defaults::new_presentation(None);
let titled = |id, x, size: f64, text: &str| {
let mut body = TextBody::from_text(text);
for para in &mut body.paragraphs {
for r in &mut para.runs {
r.props.size = Some(size);
}
}
Shape { xfrm: Some(Xfrm::new(x, 100.0, 600.0, 120.0)), text: Some(body), ..shape(id, "Title 1") }
};
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![titled(1, 100.0, 60.0, "TEST")];
b.shapes = vec![titled(2, 40.0, 44.0, "TEST")];
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert_eq!(f.text.len(), 1, "the shared text morphs");
assert!(!f.text[0].chars);
// Different texts still cross-fade with the Objects option.
b.shapes = vec![titled(2, 40.0, 44.0, "Other")];
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
}
#[test]
fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() {
// #116: a video set to play in the click sequence starts with its step, and the step doesn't
// last as long as the video (the next click goes on while it plays).
let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) };
let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H);
assert_eq!(tl.steps(), 2);
assert_eq!(tl.step_duration(0), 0.0);
let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0);
assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::<Vec<_>>(), [(ShapeId(1), 0)]);
assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once");
assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects");
// An After Previous effect still waits for the video.
let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H);
assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0));
}
/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully
/// built; without it, or after jumping elsewhere, the show carries on in order.
#[test]
fn show_returns_to_the_zoom_slide() {
use deckcraft_model::text::{Action, Hyperlink};
let p = pres(6);
let link = |to: u32, last: Option<u32>| Hyperlink {
action: Action::Slide { slide: SlideId(1000 + to) },
tooltip: String::new(),
highlight_click: false,
zoom_return: last.map(|l| SlideId(1000 + l)),
sound: None,
stop_sound: false,
};
let mut s = ShowState::new(&p);
// A section zoom on slide 0 for slides 2–3.
assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.next(&p), ShowAction::Slide(3));
assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom");
assert_eq!(s.step, ShowState::steps(&p, 0));
assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order");
// Without Return to Zoom the show carries on.
let mut s = ShowState::new(&p);
s.follow_link(&p, &link(4, None), None);
assert_eq!(s.next(&p), ShowAction::Slide(5));
// Jumping elsewhere (See All Slides, a number) forgets the zoom.
let mut s = ShowState::new(&p);
s.follow_link(&p, &link(2, Some(2)), None);
s.goto(&p, 4);
assert_eq!(s.next(&p), ShowAction::Slide(5));
}
+364
View File
@@ -0,0 +1,364 @@
//! Clicks, triggered animations and what the show remembers about visited slides.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use deckcraft_model::text::Action;
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
use crate::*;
fn shape(id: u32) -> Shape {
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
}
/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`.
fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation {
let effect = if ms == 0 { "disappear" } else { "fadeOut" };
Animation {
shape: ShapeId(target),
class: AnimClass::Exit,
effect: effect.into(),
start: AnimStart::OnClick,
duration_ms: ms,
trigger: Some(ShapeId(trigger)),
..Default::default()
}
}
fn slide(id: u32, shapes: Vec<Shape>, animations: Vec<Animation>, advance_on_click: bool) -> Arc<Slide> {
let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() });
Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() })
}
fn to_slide(n: u32) -> Option<Action> {
Some(Action::Slide { slide: SlideId(n) })
}
fn hit(id: u32, link: Option<Action>) -> HitShape {
HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true }
}
/// Test driver: a show plus the click helpers the slide show window uses.
struct Run {
pres: Presentation,
show: ShowState,
/// Hit shapes of each slide, back to front.
hits: Vec<Vec<HitShape>>,
now: f64,
}
impl Run {
fn new(pres: Presentation, hits: Vec<Vec<HitShape>>) -> Run {
let show = ShowState::new(&pres);
Run { pres, show, hits, now: 0.0 }
}
fn tl(&self) -> Timeline {
ShowState::timeline(&self.pres, self.show.slide)
}
fn visible(&self, id: ShapeId) -> bool {
let st = self.show.anim_state(&self.tl(), id, 0.0);
st.visible && st.opacity > 0.0
}
fn opacity(&self, id: u32) -> f64 {
self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity
}
fn click(&mut self) -> Click {
let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone());
let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect();
let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1);
self.show.click(&self.pres, &tl, &hits, &visible, self.now, None)
}
fn target(&self) -> Option<Target> {
let (tl, hits) = (self.tl(), &self.hits[self.show.slide]);
self.show.target(&self.pres, &tl, hits, &|id| self.visible(id))
}
/// Let `secs` pass on the shown slide, in small frames.
fn wait(&mut self, secs: f64) {
let end = self.now + secs;
while self.now < end - 1e-9 {
self.now = (self.now + 0.05).min(end);
let tl = self.tl();
self.show.tick(&tl, self.now);
}
}
}
/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a
/// picture on top of both that disappears (4); slide 2 has a button back to slide 1.
fn two_slides(advance_on_click: bool) -> Run {
let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click);
let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]])
}
#[test]
fn hidden_shapes_stay_hidden_when_the_show_comes_back() {
let mut r = two_slides(false);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top");
// Click the picture: it disappears; the button beneath it is now the target.
assert_eq!(r.click(), Click::Animated);
assert!(!r.visible(ShapeId(4)));
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2)));
// Click the button: its animation starts and the link is followed.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
assert_eq!(r.show.slide, 1);
// Slide 2: the button goes back.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
assert_eq!(r.show.slide, 0);
// Both are still gone, and nothing on the slide can be clicked any more.
assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
assert_eq!(r.target(), None);
// "Advance on click" is off: a click anywhere does nothing, however often.
for _ in 0..3 {
assert_eq!(r.click(), Click::None);
}
assert_eq!(r.show.slide, 0);
}
#[test]
fn background_click_advances_when_the_slide_allows_it() {
let mut r = two_slides(true);
r.click(); // the picture disappears
r.click(); // the button disappears and links to slide 2
r.click(); // the button there links back
assert_eq!(r.show.slide, 0);
assert_eq!(r.target(), None);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
}
#[test]
fn advance_on_click_off_still_plays_animation_steps() {
let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
let s1 = slide(1000, vec![shape(1)], vec![main], false);
let s2 = slide(1001, vec![], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]);
// The first click plays the step, the next finishes it; then the slide stays.
assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0))));
assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0))));
assert_eq!(r.click(), Click::None);
assert_eq!(r.show.slide, 0);
// The arrow keys still move on.
assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1));
}
#[test]
fn a_shape_without_an_action_blocks_the_one_beneath_it() {
let s = slide(1000, vec![shape(1), shape(2)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]);
assert_eq!(r.target(), None, "the plain shape on top blocks the button");
// So the click is a background click, not the button's link.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
// Reversed, the button is on top and is the target.
let s = slide(1000, vec![shape(2), shape(1)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
}
#[test]
fn groups_answer_for_their_children() {
let s = slide(1000, vec![shape(1)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true };
let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true };
// A child of a group with an action acts as the group.
let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10)));
// Empty space inside the box of a group without an action blocks nothing.
let behind = hit(5, to_slide(1001));
let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5)));
}
#[test]
fn pointer_outside_a_shape_is_not_on_it() {
let s = slide(1000, vec![shape(1)], vec![], true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let mut h = hit(1, to_slide(1000));
h.under_pointer = false;
let r = Run::new(pres, vec![vec![h]]);
assert_eq!(r.target(), None);
}
#[test]
fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() {
// Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s.
let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
assert_eq!(r.click(), Click::Animated);
r.wait(2.0);
assert!(r.show.trigger_playing());
assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade");
assert!(r.visible(ShapeId(3)));
// A click on the shape while its step plays completes the step at once...
assert_eq!(r.click(), Click::Animated);
assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing());
// ...and the next click plays the next step.
assert_eq!(r.click(), Click::Animated);
r.wait(5.2);
assert!(!r.visible(ShapeId(3)));
// All steps have played, and the shape still answers: its sequence starts over.
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
assert_eq!(r.click(), Click::Animated);
assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone");
r.wait(5.2);
assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
}
#[test]
fn a_ring_of_triggers_goes_round_and_round() {
// Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again.
let ring = 4u32;
let anims: Vec<Animation> = (1..=ring)
.flat_map(|k| {
let next = k % ring + 1;
let show = Animation {
shape: ShapeId(next),
class: AnimClass::Entrance,
effect: "appear".into(),
start: AnimStart::WithPrevious,
duration_ms: 0,
trigger: Some(ShapeId(k)),
..Default::default()
};
[exit_on_click(k, k, 0), show]
})
.collect();
let shapes: Vec<Shape> = (1..=ring).map(shape).collect();
let s = slide(1000, shapes, anims, true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let hits: Vec<HitShape> = (1..=ring).map(|k| hit(k, None)).collect();
let mut r = Run::new(pres, vec![hits]);
let showing = |r: &Run| -> Vec<u32> { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() };
assert_eq!(showing(&r), vec![1]);
for lap in 0..3 {
for k in 1..=ring {
assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}");
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k)));
assert_eq!(r.click(), Click::Animated);
}
}
assert_eq!(showing(&r), vec![1]);
}
#[test]
fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() {
// Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2.
let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false);
let s2 = slide(1001, vec![shape(5)], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]);
// Click the animated shape, wait 2 s, click the link.
r.click();
r.wait(2.0);
assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0);
// The second shape is not over the first one, so click the link directly.
let tl = r.tl();
let hits = vec![hit(2, to_slide(1001))];
assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1)));
// However long the show stays away, the animation does not run.
r.wait(60.0);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
// Back on the slide it has 3 s left.
assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1);
assert!(r.show.trigger_playing());
r.wait(2.5);
assert!(r.show.trigger_playing() && r.opacity(4) > 0.0);
r.wait(1.0);
assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4)));
}
#[test]
fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() {
let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false);
let s2 = slide(1001, vec![shape(5)], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
r.wait(30.0);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
// The whole animation is still ahead.
assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95);
assert!(r.show.trigger_playing());
r.wait(5.5);
assert!(!r.visible(ShapeId(2)));
}
#[test]
fn a_slide_comes_back_the_way_it_was_left() {
let steps: Vec<Animation> = (1..=3)
.map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() })
.collect();
let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true);
let s2 = slide(1001, vec![], vec![], true);
let s3 = slide(1002, vec![], vec![], true);
let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() };
let mut s = ShowState::new(&pres);
s.next(&pres);
s.step_done(&pres);
assert_eq!(s.step, 1);
// A link away and back: still after step 1.
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0)));
assert_eq!((s.step, s.playing), (1, false));
// So does the back arrow.
s.goto(&pres, 1);
assert_eq!(s.prev(&pres), ShowAction::Slide(0));
assert_eq!(s.step, 1);
// A slide the show has not been to is entered at its start; going back to one shows it fully
// built.
let mut s = ShowState::new(&pres);
s.goto(&pres, 2);
assert_eq!(s.prev(&pres), ShowAction::Slide(1));
assert_eq!(s.step, ShowState::steps(&pres, 1));
}
#[test]
fn a_playing_main_step_is_finished_when_the_show_leaves() {
let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
let s1 = slide(1000, vec![shape(1)], vec![a], true);
let s2 = slide(1001, vec![], vec![], true);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut s = ShowState::new(&pres);
assert_eq!(s.next(&pres), ShowAction::PlayStep(0));
s.goto(&pres, 1);
s.goto(&pres, 0);
assert_eq!((s.step, s.playing), (1, false));
}
#[test]
fn show_without_animation_ignores_triggers() {
let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true);
let mut pres = Presentation { slides: vec![s], ..Default::default() };
pres.show.without_animation = true;
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]);
assert_eq!(r.target(), None);
}
#[test]
fn clocks_and_pointers_with_hostile_numbers_are_harmless() {
let mut r = two_slides(true);
let tl = r.tl();
for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] {
r.show.tick(&tl, now);
let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None);
}
// A parent index that points nowhere, or in a circle, is not followed forever.
let bad = vec![
HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true },
HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true },
];
assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None);
}
+90 -18
View File
@@ -1,5 +1,7 @@
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
use std::collections::{HashMap, HashSet};
use deckcraft_model::anim::TextBuild;
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
@@ -57,6 +59,16 @@ pub struct TriggerPlay {
pub t: f64,
}
/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects
/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again
/// by another ends up as the later of the two left it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StepStamp {
pub trigger: ShapeId,
pub step: usize,
pub at: u64,
}
#[derive(Clone, Debug, Default)]
struct StepInfo {
auto: bool,
@@ -151,25 +163,31 @@ impl Timeline {
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut tl = Timeline::default();
// Sequences in order of first appearance: main first.
let mut seqs: Vec<Sequence> = vec![Sequence::Main];
for a in &slide.animations {
if let Some(tr) = a.trigger {
let s = Sequence::Trigger(tr);
if !seqs.contains(&s) {
seqs.push(s);
}
// Group the animations by sequence in one pass, sequences in order of first appearance
// (main first), so many distinct triggers cost linear work, not a scan per trigger.
let mut seqs: Vec<(Sequence, Vec<usize>)> = vec![(Sequence::Main, Vec::new())];
let mut seq_index: HashMap<Sequence, usize> = HashMap::from([(Sequence::Main, 0)]);
for (ai, a) in slide.animations.iter().enumerate() {
let s = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
let i = *seq_index.entry(s).or_insert_with(|| {
seqs.push((s, Vec::new()));
seqs.len() - 1
});
if let Some((_, members)) = seqs.get_mut(i) {
members.push(ai);
}
}
for seq in seqs {
// Paragraph groups already recorded (the list itself keeps their order).
let mut grouped: HashSet<(ShapeId, usize, usize)> = HashSet::new();
for (seq, members) in seqs {
if tl.effects.len() >= MAX_EFFECTS {
break;
}
let mut steps: Vec<StepInfo> = Vec::new();
let mut group_begin = 0.0f64;
let mut max_end = 0.0f64;
for (ai, a) in slide.animations.iter().enumerate() {
let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
if aseq != seq {
continue;
}
for ai in members {
let Some(a) = slide.animations.get(ai) else { continue };
if tl.effects.len() >= MAX_EFFECTS {
break;
}
@@ -188,7 +206,7 @@ impl Timeline {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
&& !tl.groups.contains(&(a.shape, i, o))
&& grouped.insert((a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
}
@@ -249,7 +267,9 @@ impl Timeline {
max_end = max_end.max(end);
let step = steps.len().saturating_sub(1);
if let Some(s) = steps.last_mut() {
s.duration = s.duration.max(end);
// Media plays on by itself once started: it holds back an After Previous
// effect (`max_end`), but not the next click (#116).
s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end });
}
tl.effects.push(Effect {
timing: EffectTiming {
@@ -308,6 +328,20 @@ impl Timeline {
self.main.get(i).map(|s| s.duration).unwrap_or(0.0)
}
/// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to`
/// seconds into it, in order: what a show running the step from `from` to `to` sets off.
pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec<EffectTiming> {
let mut v: Vec<EffectTiming> = self
.effects
.iter()
.map(|e| &e.timing)
.filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to)
.cloned()
.collect();
v.sort_by(|a, b| a.start.total_cmp(&b.start));
v
}
/// Total duration of the main sequence played without pauses.
pub fn total_duration(&self) -> f64 {
self.main.iter().map(|s| s.duration).sum()
@@ -325,6 +359,19 @@ impl Timeline {
(self.main.len(), 0.0)
}
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
pub fn start_of(&self, anim: usize) -> Option<f64> {
let e = self
.effects
.iter()
.map(|e| &e.timing)
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
Some(before + e.start)
}
/// Shapes that start an interactive sequence when clicked.
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
self.triggers.iter().map(|(s, _)| *s).collect()
@@ -379,6 +426,21 @@ impl Timeline {
/// [`Timeline::state`] with the playheads of triggered sequences.
pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
self.state_ordered(shape, para, step, t, triggers, &[])
}
/// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps
/// were started (`stamps`), after those of the main sequence. Without stamps they apply in
/// Animation Pane order.
pub fn state_ordered(
&self,
shape: ShapeId,
para: Option<usize>,
step: usize,
t: f64,
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
@@ -391,7 +453,16 @@ impl Timeline {
{
st.visible = false;
}
// Then play in time order: main sequence first, then triggered sequences.
// Then play in time order: main sequence first, then triggered sequences as they were clicked.
if !stamps.is_empty() {
let when = |e: &Effect| match e.timing.sequence {
Sequence::Main => 0,
Sequence::Trigger(tr) => {
stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1))
}
};
ordered.sort_by_key(|e| (when(e), e.timing.anim));
}
for e in &ordered {
let (pstep, pt) = match e.timing.sequence {
Sequence::Main => (step, t),
@@ -471,7 +542,8 @@ fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckc
EffectSpec {
class: a.class,
effect,
option: a.option.clone(),
// Sink Down is Rise Up played backwards, leaving downward.
option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() },
accel: a.smooth_start,
decel: a.smooth_end,
bounce: a.bounce_end,
+96 -821
View File
@@ -1,15 +1,18 @@
//! Slide transitions as layers of textured quads.
//!
//! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide,
//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from
//! trapezoids; wedges are triangles (4th corner repeats the 3rd).
use std::f64::consts::PI;
//! their backgrounds or contents alone, or a solid colour) onto a quad in slide-unit space (0..1, y
//! down), with an opacity per corner. Perspective comes from projecting 3-D cards
//! ([`crate::layer_mesh`] keeps their textures straight); soft edges come from per-corner opacity
//! ramps; wedges are triangles (4th corner repeats the 3rd).
//!
//! The motions, curves, directions and option meanings were measured frame by frame on
//! PowerPoint's video export of synthetic decks (`plan/powerpoint/tools/synth_transitions.ps1`).
//! The effects themselves live in [`crate::transition_fx`].
use serde::{Deserialize, Serialize};
use crate::clampf;
use crate::easing::smooth;
/// What a layer shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -20,6 +23,35 @@ pub enum Source {
New,
Black,
White,
/// The outgoing slide's background (background fill plus master and layout graphics) alone.
OldBack,
/// The incoming slide's background alone.
NewBack,
/// The outgoing slide's own shapes alone, over transparency.
OldContent,
/// The incoming slide's own shapes alone, over transparency.
NewContent,
}
impl Source {
/// The whole-slide source to draw instead when the parts aren't available: a background
/// becomes its slide, content becomes nothing.
pub fn whole(self) -> Option<Source> {
match self {
Source::OldBack => Some(Source::Old),
Source::NewBack => Some(Source::New),
Source::OldContent | Source::NewContent => None,
s => Some(s),
}
}
/// Does this source need the slide rendered in parts (background / content)?
pub fn is_part(self) -> bool {
matches!(self, Source::OldBack | Source::NewBack | Source::OldContent | Source::NewContent)
}
}
fn ones() -> [f64; 4] {
[1.0; 4]
}
/// One textured quad of a transition frame.
@@ -34,12 +66,18 @@ pub struct Layer {
/// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these.
pub uv: [[f64; 2]; 4],
pub alpha: f64,
/// Opacity of each corner (tl, tr, br, bl), multiplied by `alpha` and interpolated across the
/// quad: soft edges and feathered masks.
#[serde(default = "ones")]
pub fade: [f64; 4],
}
type R = [f64; 4];
const FULL: R = [0.0, 0.0, 1.0, 1.0];
pub(crate) type R = [f64; 4];
pub(crate) type P = [f64; 2];
pub(crate) type Q = [[f64; 2]; 4];
pub(crate) const FULL: R = [0.0, 0.0, 1.0, 1.0];
fn corners(r: R) -> [[f64; 2]; 4] {
pub(crate) fn corners(r: R) -> Q {
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
}
@@ -50,23 +88,39 @@ impl Layer {
}
/// `src` rectangle of the source drawn into the `dst` rectangle.
pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer {
Layer { source, src, quad: corners(dst), uv: corners(src), alpha }
Layer { source, src, quad: corners(dst), uv: corners(src), alpha, fade: ones() }
}
/// `src` rectangle of the source drawn onto a quad.
pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer {
Layer { source, src, quad, uv: corners(src), alpha }
pub fn quad(source: Source, src: R, quad: Q, alpha: f64) -> Layer {
Layer { source, src, quad, uv: corners(src), alpha, fade: ones() }
}
/// The part of the source under `dst` drawn in place.
fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
pub(crate) fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
Layer::rect(source, dst, dst, alpha)
}
/// A triangle of the source drawn in place.
fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer {
let xs = [a[0], b[0], c[0]];
let ys = [a[1], b[1], c[1]];
/// A triangle with its own texture coordinates and corner opacities.
pub(crate) fn tri_uv(source: Source, p: [P; 3], uv: [P; 3], fade: [f64; 3], alpha: f64) -> Layer {
let xs = [uv[0][0], uv[1][0], uv[2][0]];
let ys = [uv[0][1], uv[1][1], uv[2][1]];
let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min);
let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha }
Layer {
source,
src: [mn(xs), mn(ys), mx(xs), mx(ys)],
quad: [p[0], p[1], p[2], p[2]],
uv: [uv[0], uv[1], uv[2], uv[2]],
alpha,
fade: [fade[0], fade[1], fade[2], fade[2]],
}
}
/// With corner opacities.
pub(crate) fn faded(mut self, fade: [f64; 4]) -> Layer {
self.fade = fade;
self
}
/// A black copy of this layer's shape at `k` of its opacity: shading a card.
pub(crate) fn shade(&self, k: f64) -> Layer {
Layer { source: Source::Black, alpha: self.alpha * k.clamp(0.0, 1.0), ..*self }
}
fn sanitized(mut self) -> Layer {
let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 };
@@ -81,827 +135,48 @@ impl Layer {
c[0] = c[0].clamp(0.0, 1.0);
c[1] = c[1].clamp(0.0, 1.0);
}
for a in self.fade.iter_mut() {
*a = clampf(*a, 0.0, 1.0, 0.0);
}
self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0);
self
}
}
// ---------- geometry helpers ----------
fn offset(r: R, dx: f64, dy: f64) -> R {
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
}
fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
}
/// The whole source scaled about (cx, cy) and moved.
fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy];
Layer::rect(source, FULL, r, alpha)
}
/// Rotate a point about a pivot (degrees).
fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] {
let (s, co) = deg.to_radians().sin_cos();
let (x, y) = (p[0] - c[0], p[1] - c[1]);
[c[0] + x * co - y * s, c[1] + x * s + y * co]
}
fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] {
[rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)]
}
/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end).
fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] {
let h = r[3] - r[1];
[[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]]
}
/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end).
fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] {
let w = r[2] - r[0];
[[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]]
}
/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction).
fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R {
let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r };
if mx {
r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]];
}
if my {
r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]];
}
r
}
/// Motion direction of an option: l, r, u, d and diagonals.
fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
let mut d = (0.0, 0.0);
if opt.contains('l') {
d.0 = -1.0;
}
if opt.contains('r') {
d.0 = 1.0;
}
if opt.contains('u') {
d.1 = -1.0;
}
if opt.contains('d') {
d.1 = 1.0;
}
if d == (0.0, 0.0) { default } else { d }
}
fn hash64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 31)
}
/// Deterministic pseudo-random value in [0, 1).
fn rnd(i: u64) -> f64 {
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
}
fn ramp(t: f64, order: f64, window: f64) -> f64 {
let w = window.clamp(0.01, 1.0);
((t - order * (1.0 - w)) / w).clamp(0.0, 1.0)
}
fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> {
let mut v = Vec::with_capacity(cols * rows);
for r in 0..rows {
for c in 0..cols {
let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64];
v.push((c, r, cell));
}
}
v
}
fn center(r: R) -> [f64; 2] {
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
}
fn scale_rect(r: R, s: f64) -> R {
let c = center(r);
let hw = (r[2] - r[0]) / 2.0 * s;
let hh = (r[3] - r[1]) / 2.0 * s;
[c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh]
}
// ---------- effects ----------
/// Slide aspect assumed by [`transition_layers`] (16:9).
pub const DEFAULT_ASPECT: f64 = 16.0 / 9.0;
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
/// frames with [`crate::morph_frame`]).
/// progress `t` (0..1) on a 16:9 slide. See [`transition_frame`].
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
transition_frame(kind, option, t, DEFAULT_ASPECT)
}
/// Layers of the transition `kind` with `option` at linear progress `t` (0..1; the effect applies
/// its own easing) on a slide `aspect` wide per unit of height. Draw in order over black. At
/// `t >= 1` the result is the new slide alone; at `t <= 0` (or NaN) the old slide alone. Morph
/// returns a plain crossfade (the UI renders morph frames with [`crate::morph_frame`]). Layers
/// with [`Source::is_part`] sources need the slides rendered as background and content
/// ([`uses_slide_parts`]).
pub fn transition_frame(kind: &str, option: &str, t: f64, aspect: f64) -> Vec<Layer> {
let t = clampf(t, 0.0, 1.0, 0.0);
let aspect = if aspect.is_finite() && aspect > 0.01 { aspect.min(100.0) } else { DEFAULT_ASPECT };
let v = if t >= 1.0 {
vec![Layer::full(Source::New, 1.0)]
} else if t <= 0.0 {
vec![Layer::full(Source::Old, 1.0)]
} else {
layers(kind, option, t, 0)
crate::transition_fx::layers(kind, option, t, aspect, 0)
};
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect()
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0 && l.fade.iter().any(|a| *a > 0.0)).collect()
}
fn crossfade(t: f64) -> Vec<Layer> {
vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)]
}
const RANDOM_KINDS: &[&str] = &[
"fade",
"push",
"wipe",
"split",
"reveal",
"randomBar",
"shape",
"uncover",
"cover",
"fallOver",
"drape",
"curtains",
"wind",
"prestige",
"fracture",
"crush",
"peelOff",
"pageCurlDouble",
"airplane",
"origami",
"dissolve",
"checker",
"blinds",
"clock",
"ripple",
"honeycomb",
"glitter",
"vortex",
"shred",
"switch",
"flip",
"gallery",
"cube",
"doors",
"box",
"comb",
"zoom",
"pan",
"ferris",
"conveyor",
"rotate",
"window",
"orbit",
"flythrough",
];
fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec<Layer> {
use Source::{Black, New, Old, White};
let o = option;
let s = smooth(t);
/// Does `kind` animate the slides' backgrounds and contents separately (PowerPoint's Dynamic
/// Content transitions and Reveal)? Then the frame uses [`Source::OldBack`] & co.
pub fn uses_slide_parts(kind: &str, option: &str) -> bool {
match kind {
"none" => vec![Layer::full(New, 1.0)],
"cut" => {
if o == "throughBlack" && t < 0.5 {
vec![Layer::full(Black, 1.0)]
} else {
vec![Layer::full(New, 1.0)]
}
}
"fade" if o == "throughBlack" => {
if t < 0.5 {
vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)]
} else {
vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)]
}
}
"fade" | "morph" => crossfade(t),
"push" | "pan" => {
let (dx, dy) = dir(o, (0.0, -1.0));
vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
}
"uncover" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)]
}
"cover" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
}
"wipe" => wipe(o, t),
"split" => {
let horz = o.starts_with("horz");
let mut v = vec![Layer::full(Old, 1.0)];
let rects: Vec<R> = if o.ends_with("In") {
vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]]
} else {
vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]]
};
for r in rects {
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
v
}
"reveal" => {
let dx = if o.ends_with("Right") { 1.0 } else { -1.0 };
if o.starts_with("black") {
if t < 0.5 {
vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)]
} else {
vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)]
}
} else {
vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)]
}
}
"randomBar" => {
let horz = o == "horz";
let n = 48;
let mut v = vec![Layer::full(Old, 1.0)];
for i in 0..n {
if t > rnd(i as u64 + 17) * 0.98 {
let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0];
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
}
v
}
"shape" => {
let form = match o {
"circle" | "diamond" | "plus" => o,
_ => "box",
};
let inward = o == "in";
if inward {
let mut v = vec![Layer::full(New, 1.0)];
v.extend(shape_form(Old, form, 1.0 - s));
v
} else {
let mut v = vec![Layer::full(Old, 1.0)];
v.extend(shape_form(New, form, s));
v
}
}
"flash" => {
if t < 0.5 {
vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))]
} else {
vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)]
}
}
"fallOver" => {
let sg = if o == "r" { 1.0 } else { -1.0 };
let a = t * t * PI / 2.0;
let top = 1.0 - a.cos();
let ins = 0.12 * a.sin();
let sh = sg * 0.15 * a.sin();
let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]];
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))]
}
"drape" => {
let k = t * t;
let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]];
let q = if o == "r" { mirror_q(q) } else { q };
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)]
}
"curtains" => {
let w = 0.5 * (1.0 - s);
let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]];
let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]];
vec![
scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0),
Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0),
Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0),
]
}
"wind" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let r = offset(FULL, d * 1.2 * s, -0.1 * s);
let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]];
vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)]
}
"prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)],
"fracture" => {
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(8, 6) {
let i = (r * 8 + c) as u64;
let q = ramp(t, rnd(i + 101) * 0.5, 0.5);
let cc = center(cell);
let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q);
let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q);
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
}
v
}
"crush" => {
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(4, 4) {
let i = (r * 4 + c) as u64;
let cc = center(cell);
let k = 1.0 - s;
let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k];
let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3)));
let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]);
let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s);
v.push(Layer::quad(Old, cell, quad, 1.0 - t * t));
}
v
}
"peelOff" => {
let mut v = vec![Layer::full(New, 1.0)];
v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r"));
v
}
"pageCurlDouble" => {
let mut v = vec![Layer::full(New, 1.0)];
let left_first = o != "r";
v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first));
v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first));
v
}
"airplane" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = 1.0 - 0.8 * s;
let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s);
let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s });
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)]
}
"origami" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let mut v = vec![Layer::full(New, 1.0)];
if t < 0.5 {
let f = t * 2.0;
let w = 0.5 * (1.0 - 0.8 * f);
v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0));
v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0));
} else {
let f = (t - 0.5) * 2.0;
let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f);
v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f));
}
v
}
"dissolve" => {
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(32, 18) {
if t > rnd((r * 32 + c) as u64 + 991) * 0.98 {
v.push(Layer::inplace(New, cell, 1.0));
}
}
v
}
"checker" => {
let vert = o == "vert";
let (n, m) = (10usize, 8usize);
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(n, m) {
let across = if vert { c } else { r };
let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0);
if f <= 0.0 {
continue;
}
let rc = if vert {
[cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f]
} else {
[cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]]
};
v.push(Layer::inplace(New, rc, 1.0));
}
v
}
"blinds" => {
let horz = o == "horz";
let n = 8;
let mut v = vec![Layer::full(Old, 1.0)];
for i in 0..n {
let x0 = i as f64 / n as f64;
let r = [x0, 0.0, x0 + s / n as f64, 1.0];
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
v
}
"clock" => clock(o, s),
"ripple" => {
let origin = match o {
"lu" => [0.0, 0.0],
"ru" => [1.0, 0.0],
"ld" => [0.0, 1.0],
"rd" => [1.0, 1.0],
_ => [0.5, 0.5],
};
let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 };
let mut v = vec![Layer::full(Old, 1.0)];
for (_, _, cell) in grid(24, 14) {
let cc = center(cell);
let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd;
let a = ramp(t, d.min(1.0), 0.3);
if a > 0.0 {
let sc = 1.0 + 0.15 * (a * PI).sin();
v.push(Layer::rect(New, cell, scale_rect(cell, sc), a));
}
}
v
}
"honeycomb" => {
let (cols, rows) = (12usize, 8usize);
let mut v = vec![Layer::full(Old, 1.0)];
for c in 0..cols {
let odd = c % 2 == 1;
let extra = if odd { 1 } else { 0 };
for r in 0..rows + extra {
let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64;
let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)];
let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35);
if a > 0.0 {
v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a));
}
}
}
v
}
"glitter" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(20, 12) {
let cc = center(cell);
let grad = if dx < 0.0 {
1.0 - cc[0]
} else if dx > 0.0 {
cc[0]
} else if dy < 0.0 {
1.0 - cc[1]
} else {
cc[1]
};
let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77);
let a = ramp(t, order, 0.25);
if a > 0.0 {
let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0);
v.push(Layer::quad(New, cell, quad, a));
}
}
v
}
"vortex" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(16, 9) {
let i = (r * 16 + c) as u64;
let cc = center(cell);
let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy);
let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4);
let ang = q * 2.0 * PI + rnd(i) * PI;
let rad = q * 1.2;
let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3);
let pos = [cc[0] + ox, cc[1] + oy];
let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0);
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
}
v
}
"shred" => {
let mut v = vec![Layer::full(New, 1.0)];
if o == "particles" {
for (c, r, cell) in grid(24, 14) {
let i = (r * 24 + c) as u64;
let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4);
let dy = q * q * 1.2;
let dx = (rnd(i + 9) - 0.5) * 0.3 * q;
v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q));
}
} else {
let n = 16;
for i in 0..n {
let x0 = i as f64 / n as f64;
let x1 = (i + 1) as f64 / n as f64;
let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7);
let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q };
v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0));
}
}
v
}
"switch" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = (t * PI).sin();
let old = Layer::quad(
Old,
FULL,
persp_h(
offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0),
if d > 0.0 { 0.0 } else { 0.08 * k },
if d > 0.0 { 0.08 * k } else { 0.0 },
),
1.0,
);
let new = Layer::quad(
New,
FULL,
persp_h(
offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0),
if d > 0.0 { 0.08 * k } else { 0.0 },
if d > 0.0 { 0.0 } else { 0.08 * k },
),
1.0,
);
if t < 0.5 { vec![new, old] } else { vec![old, new] }
}
"flip" => {
let a = PI * s;
let w = a.cos().abs();
let k = 0.1 * a.sin();
let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0];
let right = o != "l";
if t < 0.5 {
let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) };
vec![Layer::quad(Old, FULL, q, 1.0)]
} else {
let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) };
vec![Layer::quad(New, FULL, q, 1.0)]
}
}
"gallery" | "conveyor" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = (s * PI).sin();
let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k;
let gap = if kind == "gallery" { 1.15 } else { 1.05 };
let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0);
let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0);
let p = 0.08 * k;
vec![
Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0),
Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0),
]
}
"cube" | "box" | "rotate" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let vertical = dy != 0.0 && dx == 0.0;
let neg = if vertical { dy < 0.0 } else { dx < 0.0 };
let k = (s * PI).sin();
let (far_old, far_new, shared) = match kind {
"cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k),
"box" => (0.0, 0.0, 0.18 * k),
_ => (0.25 * s, 0.25 * (1.0 - s), 0.0),
};
// Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1].
let split = 1.0 - s;
let (old_q, new_q) = if kind == "rotate" {
if t < 0.5 {
(Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None)
} else {
(None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new)))
}
} else {
(Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new)))
};
let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] {
let q = if neg { q } else { mirror_q(q) };
if vertical { swap_q(q) } else { q }
};
let mut v = vec![Layer::full(Black, 1.0)];
if let Some(q) = old_q {
v.push(Layer::quad(Old, FULL, fix(q), 1.0));
}
if let Some(q) = new_q {
v.push(Layer::quad(New, FULL, fix(q), 1.0));
}
v
}
"doors" | "window" => {
let horz = o == "horz";
let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) };
let w = 0.5 * (1.0 - s);
let k = persp * s;
let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]];
let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]];
let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) };
let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) };
vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)]
}
"comb" => {
let vert = o == "vert";
let n = 8;
let mut v = Vec::new();
for i in 0..n {
let a = i as f64 / n as f64;
let b = (i + 1) as f64 / n as f64;
let d = if i % 2 == 0 { 1.0 } else { -1.0 };
let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] };
let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) };
let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) };
v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0));
v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0));
}
v
}
"zoom" => {
if o == "out" {
vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
} else {
vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)]
}
}
"orbit" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let k = (s * PI).sin();
let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s);
let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s);
if t < 0.5 { vec![new, old] } else { vec![old, new] }
}
"flythrough" => {
let bounce = o.ends_with("Bounce");
let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base };
if o.starts_with("out") {
vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)]
} else {
vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
}
}
"ferris" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let pivot = [0.5, 2.5];
let a = 50.0 * s * d;
let oq = rot_quad(corners(FULL), pivot, a);
let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d);
vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)]
}
"random" if depth == 0 => {
let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64));
let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade");
let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or("");
layers(k, opt, t, depth + 1)
}
_ => crossfade(t),
"pan" | "ferris" | "conveyor" | "rotate" | "window" | "orbit" | "flythrough" => true,
"reveal" => !option.starts_with("black"),
_ => false,
}
}
fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
// Mirror x and keep the tl, tr, br, bl order (swap left/right corners).
let m = |p: [f64; 2]| [1.0 - p[0], p[1]];
[m(q[1]), m(q[0]), m(q[3]), m(q[2])]
}
fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
// Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners.
let s = |p: [f64; 2]| [p[1], p[0]];
[s(q[0]), s(q[3]), s(q[2]), s(q[1])]
}
fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] {
persp_h(r, left_inset, right_inset)
}
fn wipe(o: &str, t: f64) -> Vec<Layer> {
let mut v = vec![Layer::full(Source::Old, 1.0)];
let diag = o.len() == 2;
if diag {
// Diagonal: columns revealed by a slanted edge.
let (mx, my) = (o.contains('l'), o.contains('u'));
let n = 32;
for i in 0..n {
let x0 = i as f64 / n as f64;
let x1 = (i + 1) as f64 / n as f64;
let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0);
if h > 0.0 {
v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0));
}
}
return v;
}
let (mx, swap) = match o {
"l" => (true, false),
"d" => (false, true),
"u" => (true, true),
_ => (false, false),
};
let soft = 0.15;
let e = t * (1.0 + soft);
let solid = (e - soft).clamp(0.0, 1.0);
if solid > 0.0 {
v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0));
}
let n = 6;
for k in 0..n {
let a = e - soft + soft * k as f64 / n as f64;
let b = e - soft + soft * (k + 1) as f64 / n as f64;
let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
if b > a {
let alpha = 1.0 - (k as f64 + 0.5) / n as f64;
v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha));
}
}
v
}
/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips.
fn shape_form(src: Source, form: &str, f: f64) -> Vec<Layer> {
let f = f.clamp(0.0, 1.0);
if f <= 0.0 {
return vec![];
}
match form {
"plus" => {
let h = f / 2.0;
vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)]
}
"circle" | "diamond" => {
let n = 40;
let r = if form == "circle" { f * 0.75 } else { f * 1.02 };
let mut v = Vec::new();
for i in 0..n {
let y0 = i as f64 / n as f64;
let y1 = (i + 1) as f64 / n as f64;
let dy = ((y0 + y1) / 2.0 - 0.5).abs();
let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy };
if half > 0.0 {
let half = half.min(0.5);
v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0));
}
}
v
}
_ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)],
}
}
/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap.
fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec<Layer> {
let w = area[2] - area[0];
let p = s * w;
if toward_right {
let edge = area[0] + p;
let stay = [edge, area[1], area[2], area[3]];
let flap_src = [area[0], area[1], edge, area[3]];
let x2 = edge + p;
let flap = Layer {
source: src,
src: flap_src,
quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]],
uv: corners(flap_src),
alpha: 0.85,
};
vec![Layer::inplace(src, stay, 1.0), flap]
} else {
let edge = area[2] - p;
let stay = [area[0], area[1], edge, area[3]];
let flap_src = [edge, area[1], area[2], area[3]];
let x2 = edge - p;
let flap = Layer {
source: src,
src: flap_src,
quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]],
uv: corners(flap_src),
alpha: 0.85,
};
vec![Layer::inplace(src, stay, 1.0), flap]
}
}
/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from
/// 12 o'clock).
fn boundary(a: f64) -> [f64; 2] {
let (dx, dy) = (a.sin(), -a.cos());
let m = dx.abs().max(dy.abs()).max(1e-9);
[0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m]
}
fn clock(o: &str, s: f64) -> Vec<Layer> {
let mut v = vec![Layer::full(Source::Old, 1.0)];
let c = [0.5, 0.5];
let sweep = s * 2.0 * PI;
let (a0, a1) = match o {
"counterClockwise" => (-sweep, 0.0),
"wedge" => (-sweep / 2.0, sweep / 2.0),
_ => (0.0, sweep),
};
let span = a1 - a0;
if span <= 0.0 {
return v;
}
let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96);
let mut angles: Vec<f64> = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect();
// Square corners inside the sweep, so the fan reaches them.
for k in -8i32..=8 {
let a = PI / 4.0 + k as f64 * PI / 2.0;
if a > a0 && a < a1 {
angles.push(a);
}
}
angles.sort_by(f64::total_cmp);
for w in angles.windows(2) {
if let [x, y] = w
&& y > x
{
v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0));
}
}
v
}
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
//! Geometry for transition effects: easing, 3-D cards, opacity fields, polygons.
use std::f64::consts::PI;
use crate::easing::{ease, smooth};
use crate::transition::{FULL, Layer, P, Q, R, Source, corners};
// ---------- easing ----------
/// Smoothstep ease in-out (PowerPoint's fades).
pub(crate) fn ss(x: f64) -> f64 {
smooth(x)
}
/// Quadratic ease in-out: velocity ramps up for the first half and down for the second
/// (PowerPoint's moves: Push, Cover, Uncover, Pan…).
pub(crate) fn q2(x: f64) -> f64 {
ease(x, 0.5, 0.5, 0.0)
}
/// Power ease in-out of degree `n` (2: quadratic, 3: cubic, 4: quartic).
pub(crate) fn pow_inout(x: f64, n: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
if x < 0.5 { 0.5 * (2.0 * x).powf(n) } else { 1.0 - 0.5 * (2.0 - 2.0 * x).powf(n) }
}
/// Quadratic ease-in.
pub(crate) fn ein(x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
x * x
}
/// Quadratic ease-out.
pub(crate) fn eout(x: f64) -> f64 {
let x = 1.0 - x.clamp(0.0, 1.0);
1.0 - x * x
}
/// Progress through the window [a, b] of t, clamped to 0..1.
pub(crate) fn win(t: f64, a: f64, b: f64) -> f64 {
if b <= a {
return if t >= b { 1.0 } else { 0.0 };
}
((t - a) / (b - a)).clamp(0.0, 1.0)
}
// ---------- randomness ----------
pub(crate) fn hash64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 31)
}
/// Deterministic pseudo-random value in [0, 1).
pub(crate) fn rnd(i: u64) -> f64 {
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
}
// ---------- 2-D ----------
pub(crate) fn offset(r: R, dx: f64, dy: f64) -> R {
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
}
pub(crate) fn center(r: R) -> P {
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
}
pub(crate) fn scale_rect(r: R, s: f64) -> R {
scale_rect_about(r, s, center(r))
}
pub(crate) fn scale_rect_about(r: R, s: f64, c: P) -> R {
[c[0] + (r[0] - c[0]) * s, c[1] + (r[1] - c[1]) * s, c[0] + (r[2] - c[0]) * s, c[1] + (r[3] - c[1]) * s]
}
/// The whole source moved by (dx, dy).
pub(crate) fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
}
/// The whole source scaled by `s` about (cx, cy) and moved.
pub(crate) fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
Layer::rect(source, FULL, offset(scale_rect_about(FULL, s, [cx, cy]), dx, dy), alpha)
}
/// Rotate a point about a pivot by `deg` (clockwise on screen), in a slide `a` wide per unit of
/// height, so angles and shapes stay true.
pub(crate) fn rot(p: P, c: P, deg: f64, a: f64) -> P {
let (s, co) = deg.to_radians().sin_cos();
let (x, y) = ((p[0] - c[0]) * a, p[1] - c[1]);
[c[0] + (x * co - y * s) / a, c[1] + x * s + y * co]
}
pub(crate) fn rot_quad(q: Q, c: P, deg: f64, a: f64) -> Q {
[rot(q[0], c, deg, a), rot(q[1], c, deg, a), rot(q[2], c, deg, a), rot(q[3], c, deg, a)]
}
/// Motion direction of an option: l, r, u, d and diagonals.
pub(crate) fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
let mut d = (0.0, 0.0);
if opt.contains('l') {
d.0 = -1.0;
}
if opt.contains('r') {
d.0 = 1.0;
}
if opt.contains('u') {
d.1 = -1.0;
}
if opt.contains('d') {
d.1 = 1.0;
}
if d == (0.0, 0.0) { default } else { d }
}
/// A rectangle with a soft border `f` wide (slide units) fading out to nothing: the rectangle at
/// full opacity, four edge ramps and four corner ramps. `src` maps onto `dst`; the border samples
/// the texture's edge.
pub(crate) fn soft_rect(source: Source, src: R, dst: R, f: f64, alpha: f64) -> Vec<Layer> {
let mut v = vec![Layer::rect(source, src, dst, alpha)];
if f <= 1e-6 {
return v;
}
let (l, t, r, b) = (dst[0], dst[1], dst[2], dst[3]);
let (fx, fy) = (f, f * 16.0 / 9.0);
let uv_at = |x: f64, y: f64| -> P {
let u = if r > l { src[0] + (x.clamp(l, r) - l) / (r - l) * (src[2] - src[0]) } else { src[0] };
let w = if b > t { src[1] + (y.clamp(t, b) - t) / (b - t) * (src[3] - src[1]) } else { src[1] };
[u, w]
};
let mut q = |pts: Q, fade: [f64; 4]| {
let uv = pts.map(|p| uv_at(p[0], p[1]));
let xs = uv.map(|p| p[0]);
let ys = uv.map(|p| p[1]);
let mn = |a: [f64; 4]| a.iter().copied().fold(f64::INFINITY, f64::min);
let mx = |a: [f64; 4]| a.iter().copied().fold(f64::NEG_INFINITY, f64::max);
v.push(Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: pts, uv, alpha, fade });
};
q([[l, t - fy], [r, t - fy], [r, t], [l, t]], [0.0, 0.0, 1.0, 1.0]);
q([[l, b], [r, b], [r, b + fy], [l, b + fy]], [1.0, 1.0, 0.0, 0.0]);
q([[l - fx, t], [l, t], [l, b], [l - fx, b]], [0.0, 1.0, 1.0, 0.0]);
q([[r, t], [r + fx, t], [r + fx, b], [r, b]], [1.0, 0.0, 0.0, 1.0]);
q([[l - fx, t - fy], [l, t - fy], [l, t], [l - fx, t]], [0.0, 0.0, 1.0, 0.0]);
q([[r, t - fy], [r + fx, t - fy], [r + fx, t], [r, t]], [0.0, 0.0, 0.0, 1.0]);
q([[r, b], [r + fx, b], [r + fx, b + fy], [r, b + fy]], [1.0, 0.0, 0.0, 0.0]);
q([[l - fx, b], [l, b], [l, b + fy], [l - fx, b + fy]], [0.0, 1.0, 0.0, 0.0]);
v
}
// ---------- opacity fields ----------
/// The source shown in place through an opacity field `f(x, y)` (0..1) sampled on an `n` × `m`
/// grid: each cell is a quad with its corners' opacities. Cells with nothing to show are left out
/// and runs of fully opaque cells merge, so most frames stay small.
pub(crate) fn field(source: Source, n: usize, m: usize, f: &dyn Fn(f64, f64) -> f64) -> Vec<Layer> {
let (n, m) = (n.clamp(1, 256), m.clamp(1, 256));
let mut vals = vec![0.0; (n + 1) * (m + 1)];
for j in 0..=m {
for i in 0..=n {
let v = f(i as f64 / n as f64, j as f64 / m as f64);
if let Some(x) = vals.get_mut(j * (n + 1) + i) {
*x = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
}
}
}
let at = |i: usize, j: usize| vals.get(j * (n + 1) + i).copied().unwrap_or(0.0);
let mut out = Vec::new();
for j in 0..m {
let (y0, y1) = (j as f64 / m as f64, (j + 1) as f64 / m as f64);
let mut run: Option<usize> = None;
for i in 0..=n {
let full = i < n && at(i, j) >= 1.0 && at(i + 1, j) >= 1.0 && at(i, j + 1) >= 1.0 && at(i + 1, j + 1) >= 1.0;
if full {
run.get_or_insert(i);
continue;
}
if let Some(s) = run.take() {
out.push(Layer::inplace(source, [s as f64 / n as f64, y0, i as f64 / n as f64, y1], 1.0));
}
if i == n {
break;
}
let c = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
if c.iter().all(|v| *v <= 0.0) {
continue;
}
out.push(Layer::inplace(source, [i as f64 / n as f64, y0, (i + 1) as f64 / n as f64, y1], 1.0).faded(c));
}
}
out
}
/// Opacity of a soft edge at position `p` when the edge's middle is at `c` and it ramps over `w`.
pub(crate) fn edge(c: f64, p: f64, w: f64) -> f64 {
if w <= 1e-9 {
return if p <= c { 1.0 } else { 0.0 };
}
((c - p) / w + 0.5).clamp(0.0, 1.0)
}
/// Point on the unit square's boundary seen from its centre at angle `ang` (radians, clockwise
/// from 12 o'clock, measured on a slide `a` wide per unit of height).
pub(crate) fn boundary(ang: f64, a: f64) -> P {
let (dx, dy) = (ang.sin() / a, -ang.cos());
let m = (dx.abs() / 0.5).max(dy.abs() / 0.5).max(1e-9);
[0.5 + dx / m, 0.5 + dy / m]
}
/// The source shown in place through an angular opacity field `f(angle)` (radians clockwise from
/// 12 o'clock, 0..2π): a fan of thin wedges from the centre.
pub(crate) fn fan(source: Source, a: f64, steps: usize, f: &dyn Fn(f64) -> f64) -> Vec<Layer> {
let n = steps.clamp(8, 720);
let mut angles: Vec<f64> = (0..=n).map(|k| 2.0 * PI * k as f64 / n as f64).collect();
// The square's corners, so wedges reach them.
for c in [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]] {
let ang = ((c[0] - 0.5) * a).atan2(-(c[1] - 0.5));
angles.push(ang.rem_euclid(2.0 * PI));
}
angles.sort_by(f64::total_cmp);
angles.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
let mut out = Vec::new();
let c = [0.5, 0.5];
for w in angles.windows(2) {
let [x, y] = w else { continue };
let (fx, fy) = (f(*x).clamp(0.0, 1.0), f(*y).clamp(0.0, 1.0));
if fx <= 0.0 && fy <= 0.0 {
continue;
}
let (bx, by) = (boundary(*x, a), boundary(*y, a));
out.push(Layer::tri_uv(source, [c, bx, by], [c, bx, by], [(fx + fy) / 2.0, fx, fy], 1.0));
}
out
}
// ---------- polygons ----------
/// Keep the part of a convex polygon where `n · p <= d`.
pub(crate) fn clip(poly: &[P], n: P, d: f64) -> Vec<P> {
let mut out = Vec::with_capacity(poly.len() + 2);
let side = |p: P| n[0] * p[0] + n[1] * p[1] - d;
for (i, p) in poly.iter().enumerate() {
let q = poly.get((i + 1) % poly.len()).copied().unwrap_or(*p);
let (sp, sq) = (side(*p), side(q));
if sp <= 0.0 {
out.push(*p);
}
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
let k = sp / (sp - sq);
out.push([p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k]);
}
}
out
}
/// A convex polygon as triangles: vertices drawn at `map(p)` with texture coordinate `p`.
pub(crate) fn poly_layers(source: Source, poly: &[P], alpha: f64, map: &dyn Fn(P) -> P) -> Vec<Layer> {
let Some(first) = poly.first().copied() else { return vec![] };
poly.windows(2)
.skip(1)
.filter_map(|w| match w {
[b, c] => Some(Layer::tri_uv(source, [map(first), map(*b), map(*c)], [first, *b, *c], [1.0; 3], alpha)),
_ => None,
})
.collect()
}
pub(crate) fn rect_poly(r: R) -> Vec<P> {
corners(r).to_vec()
}
// ---------- 3-D ----------
/// A point in the 3-D scene: x right and y down in slide heights (the slide spans x ∈ [−a/2, a/2],
/// y ∈ [−½, ½] at z = 0), z away from the viewer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct V3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
pub(crate) fn v3(x: f64, y: f64, z: f64) -> V3 {
V3 { x, y, z }
}
impl V3 {
pub fn add(self, o: V3) -> V3 {
v3(self.x + o.x, self.y + o.y, self.z + o.z)
}
pub fn sub(self, o: V3) -> V3 {
v3(self.x - o.x, self.y - o.y, self.z - o.z)
}
/// Rotate about the vertical axis through `c` (degrees; positive turns the right side away).
pub fn yaw(self, c: V3, deg: f64) -> V3 {
let (s, co) = deg.to_radians().sin_cos();
let p = self.sub(c);
v3(p.x * co - p.z * s, p.y, p.x * s + p.z * co).add(c)
}
/// Rotate about the horizontal axis through `c` (degrees; positive turns the top away).
pub fn pitch(self, c: V3, deg: f64) -> V3 {
let (s, co) = deg.to_radians().sin_cos();
let p = self.sub(c);
v3(p.x, p.y * co + p.z * s, -p.y * s + p.z * co).add(c)
}
/// Rotate in the screen plane about `c` (degrees, clockwise).
pub fn roll(self, c: V3, deg: f64) -> V3 {
let (s, co) = deg.to_radians().sin_cos();
let p = self.sub(c);
v3(p.x * co - p.y * s, p.x * s + p.y * co, p.z).add(c)
}
}
/// A perspective camera looking at the slide plane from `d` slide heights away.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Cam {
pub a: f64,
pub d: f64,
}
impl Cam {
pub fn p(&self, v: V3) -> P {
let k = self.d / (self.d + v.z).max(self.d * 0.02);
[v.x * k / self.a + 0.5, v.y * k + 0.5]
}
/// The 3-D corners (tl, tr, br, bl) of the unit-space rectangle `r` lying on the slide plane.
pub fn rect(&self, r: R) -> [V3; 4] {
corners(r).map(|p| v3((p[0] - 0.5) * self.a, p[1] - 0.5, 0.0))
}
/// The unit-space point of a 3-D point on the slide plane.
pub fn unit(&self, x: f64, y: f64) -> V3 {
v3((x - 0.5) * self.a, y - 0.5, 0.0)
}
pub fn quad(&self, c: [V3; 4]) -> Q {
c.map(|v| self.p(v))
}
}
/// Signed area of a quad (positive: front side facing the viewer).
pub(crate) fn area(q: &Q) -> f64 {
let mut s = 0.0;
for i in 0..4 {
let (a, b) = (q[i], q[(i + 1) % 4]);
s += a[0] * b[1] - b[0] * a[1];
}
s / 2.0
}
/// A card: `src` of the source mapped onto 3-D corners, if it faces the viewer; `shade` (0..1)
/// darkens it. Seen from behind it is left out unless `back` is set (then drawn mirrored).
pub(crate) fn card(cam: &Cam, source: Source, src: R, c: [V3; 4], alpha: f64, shade: f64, back: bool) -> Vec<Layer> {
let q = cam.quad(c);
if !q.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
return vec![];
}
let front = area(&q) > 0.0;
if !front && !back {
return vec![];
}
let l = Layer::quad(source, src, q, alpha);
let mut v = vec![l];
if shade > 0.001 {
v.push(l.shade(shade));
}
v
}
/// How much a card facing along `n` (unit normal toward the viewer is (0, 0, −1)) is darkened by
/// the light behind the viewer: 0 facing straight on.
pub(crate) fn facing_shade(c: [V3; 4], k: f64) -> f64 {
let u = c[1].sub(c[0]);
let w = c[3].sub(c[0]);
let n = v3(u.y * w.z - u.z * w.y, u.z * w.x - u.x * w.z, u.x * w.y - u.y * w.x);
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
if len < 1e-12 {
return 0.0;
}
let cos = (n.z / len).abs();
(k * (1.0 - cos)).clamp(0.0, 1.0)
}
+230
View File
@@ -0,0 +1,230 @@
//! Transition curves and geometry against values measured on PowerPoint's video export
//! (`plan/powerpoint/tools/synth_transitions.ps1`, 2 s transitions, 30 fps).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use deckcraft_model::anim::TRANSITIONS;
use crate::transition::{Layer, Source, transition_frame, transition_layers, uses_slide_parts};
use crate::transition_geom::{edge, pow_inout, q2, ss};
const A: f64 = 16.0 / 9.0;
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn of(ls: &[Layer], src: Source) -> Vec<&Layer> {
ls.iter().filter(|l| l.source == src).collect()
}
fn left(l: &Layer) -> f64 {
l.quad.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min)
}
fn top(l: &Layer) -> f64 {
l.quad.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min)
}
#[test]
fn fade_follows_powerpoints_s_curve() {
// Measured share of the new slide in a 2 s Fade at t = 0.25, 0.5, 0.75: 0.155, 0.49, 0.82.
for (t, want) in [(0.25, 0.155), (0.5, 0.49), (0.75, 0.82)] {
let ls = transition_layers("fade", "smoothly", t);
let new = of(&ls, Source::New);
assert!(near(new[0].alpha, want, 0.04), "t={t}: {}", new[0].alpha);
}
// Through black: the old slide is gone to black at the middle, then the new one fades in.
let mid = transition_layers("fade", "throughBlack", 0.5);
assert!(mid.iter().all(|l| l.source == Source::Black));
let q = transition_layers("fade", "throughBlack", 0.25);
assert!(near(of(&q, Source::Old)[0].alpha, 0.5, 0.01));
}
#[test]
fn push_moves_both_slides_with_an_eased_curve() {
for (o, dx, dy) in [("l", -1.0, 0.0), ("r", 1.0, 0.0), ("u", 0.0, -1.0), ("d", 0.0, 1.0)] {
for t in [0.25, 0.5, 0.75] {
let ls = transition_layers("push", o, t);
let (old, new) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
let s = q2(t);
assert!(near(old.quad[0][0], dx * s, 1e-9) && near(old.quad[0][1], dy * s, 1e-9), "{o} {t}");
assert!(near(new.quad[0][0], dx * (s - 1.0), 1e-9) && near(new.quad[0][1], dy * (s - 1.0), 1e-9), "{o} {t}");
}
}
// Ease in-out: slow at both ends.
assert!(q2(0.1) < 0.05 && q2(0.9) > 0.95 && near(q2(0.5), 0.5, 1e-9));
}
#[test]
fn cover_and_uncover_match_measured_positions() {
// Offset of the new slide in Cover Left (fraction of the width) at measured times.
for (t, off) in [(0.445, 0.655), (0.495, 0.512), (0.544, 0.359), (0.643, 0.134), (0.742, 0.046)] {
let ls = transition_layers("cover", "l", t);
assert!(near(left(of(&ls, Source::New)[0]), off, 0.05), "cover t={t}");
}
// Offset of the old slide in Uncover Left.
for (t, off) in [(0.244, -0.06), (0.343, -0.14), (0.442, -0.32), (0.508, -0.5), (0.558, -0.63)] {
let ls = transition_layers("uncover", "l", t);
assert!(near(left(of(&ls, Source::Old)[0]), off, 0.05), "uncover t={t}");
}
// Diagonal Cover shoots in and settles slowly.
for (t, off) in [(0.142, 0.728), (0.257, 0.49), (0.356, 0.29), (0.455, 0.146), (0.604, 0.053)] {
let ls = transition_layers("cover", "lu", t);
let n = of(&ls, Source::New)[0];
assert!(near(left(n), off, 0.06) && near(top(n), off, 0.06), "cover lu t={t}");
}
assert!(near(pow_inout(0.25, 3.0), 0.0625, 1e-9));
}
#[test]
fn wipe_has_a_wide_soft_edge() {
// Measured along a row of Wipe Right at t = 0.5: about half-way through at x = 0.6, nearly
// nothing at the right edge, fully new on the left third.
let c = -0.4 + 1.8 * 1.05 * 0.5;
assert!(near(edge(c, 0.6, 0.8), 0.49, 0.1));
assert!(edge(c, 1.0, 0.8) < 0.1);
assert!(edge(c, 0.2, 0.8) > 0.8);
// Directions: "r" starts on the left, "l" on the right, "u" at the bottom, "d" at the top,
// diagonals at the opposite corner.
let first = |o: &str| {
let ls = transition_layers("wipe", o, 0.15);
let n = of(&ls, Source::New);
assert!(!n.is_empty(), "{o}");
let cx = n.iter().map(|l| (l.quad[0][0] + l.quad[2][0]) / 2.0).sum::<f64>() / n.len() as f64;
let cy = n.iter().map(|l| (l.quad[0][1] + l.quad[2][1]) / 2.0).sum::<f64>() / n.len() as f64;
(cx, cy)
};
assert!(first("r").0 < 0.3 && first("l").0 > 0.7);
assert!(first("u").1 > 0.7 && first("d").1 < 0.3);
let ru = first("ru");
assert!(ru.0 < 0.4 && ru.1 > 0.6, "{ru:?}");
}
#[test]
fn split_and_shape_open_from_the_middle() {
for o in ["horzOut", "vertOut"] {
let ls = transition_layers("split", o, 0.2);
let n = of(&ls, Source::New);
assert!(n.iter().all(|l| {
let c = [(l.quad[0][0] + l.quad[2][0]) / 2.0, (l.quad[0][1] + l.quad[2][1]) / 2.0];
if o.starts_with("horz") { (c[1] - 0.5).abs() < 0.4 } else { (c[0] - 0.5).abs() < 0.4 }
}));
}
// Shape In is done with the old slide at half time; Shape Out starts the new one then.
assert!(of(&transition_layers("shape", "in", 0.55), Source::Old).is_empty());
assert!(of(&transition_layers("shape", "out", 0.45), Source::New).is_empty());
}
#[test]
fn dynamic_content_curves() {
// Pan: the new content's offset (fraction of the width) at measured times.
for (t, off) in [(0.6, 0.134), (0.7, 0.086), (0.8, 0.039), (0.9, 0.011)] {
let ls = transition_layers("pan", "l", t);
assert!(near(left(of(&ls, Source::NewContent)[0]), off, 0.07), "pan t={t}");
}
// The backgrounds cross-fade under the moving contents.
let ls = transition_layers("pan", "l", 0.5);
assert_eq!(ls.first().map(|l| l.source), Some(Source::OldBack));
assert!(of(&ls, Source::NewBack)[0].alpha > 0.5);
// Ferris Wheel: the old content drops (top edge, slide heights) as measured.
for (t, dy) in [(0.147, 0.06), (0.246, 0.17), (0.345, 0.36), (0.444, 0.70)] {
let ls = transition_layers("ferris", "l", t);
let q = of(&ls, Source::OldContent)[0].quad;
let cy = q.iter().map(|p| p[1]).sum::<f64>() / 4.0 - 0.5;
assert!(near(cy, dy, 0.08), "ferris t={t}: {cy}");
}
for k in ["pan", "ferris", "conveyor", "rotate", "window", "orbit", "flythrough"] {
assert!(uses_slide_parts(k, ""), "{k}");
}
assert!(uses_slide_parts("reveal", "smoothlyLeft") && !uses_slide_parts("reveal", "blackLeft"));
assert!(!uses_slide_parts("cube", "l") && !uses_slide_parts("push", "l"));
}
#[test]
fn cube_turns_toward_the_option() {
// Cube Left at the middle: the shared edge is near the middle, the old face on the left.
let ls = transition_frame("cube", "l", 0.5, A);
let (o, n) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
let o_right = o.quad.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
assert!(near(o_right, left(n), 0.02), "shared edge: {o_right} vs {}", left(n));
assert!(o_right > 0.3 && o_right < 0.7);
// Up: the new face comes from below.
let ls = transition_frame("cube", "u", 0.5, A);
assert!(top(of(&ls, Source::New)[0]) > top(of(&ls, Source::Old)[0]));
}
#[test]
fn switch_swaps_the_cards_past_halfway() {
let order = |t: f64| {
let ls = transition_frame("switch", "l", t, A);
let pos = |s: Source| ls.iter().position(|l| l.source == s);
(pos(Source::Old), pos(Source::New))
};
let (o, n) = order(0.3);
assert!(o > n, "old in front early");
let (o, n) = order(0.8);
assert!(n > o, "new in front late");
}
#[test]
fn clock_hand_sweeps_at_constant_speed() {
// The share of the slide covered grows linearly.
let area = |t: f64| {
let ls = transition_layers("clock", "clockwise", t);
of(&ls, Source::New)
.iter()
.map(|l| {
let [a, b, c, _] = l.quad;
((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])).abs() / 2.0 * (l.fade[0] + l.fade[1] + l.fade[2]) / 3.0
})
.sum::<f64>()
};
let (a1, a2, a3) = (area(0.25), area(0.5), area(0.75));
assert!(near(a2 - a1, a3 - a2, 0.08), "{a1} {a2} {a3}");
assert!(near(a2, 0.5, 0.08), "{a2}");
}
#[test]
fn options_change_the_frame() {
// Every option of a transition draws something different from its other options midway.
for (id, _, _, _, opts) in TRANSITIONS {
if matches!(*id, "morph" | "none" | "random") || opts.len() < 2 {
continue;
}
for (i, a) in opts.iter().enumerate() {
for b in opts.iter().skip(i + 1) {
let differs = [0.2, 0.35, 0.5, 0.8].iter().any(|t| transition_layers(id, a, *t) != transition_layers(id, b, *t));
assert!(differs, "{id}: {a} vs {b}");
}
}
}
}
#[test]
fn frames_stay_cheap() {
// Per frame, the show draws these meshes from cached slide textures: keep them small.
for (id, _, _, _, opts) in TRANSITIONS {
let mut options: Vec<&str> = opts.to_vec();
options.push("");
for o in options {
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
let tris: usize = transition_frame(id, o, t, A).iter().map(|l| crate::layer_mesh(l).1.len()).sum();
assert!(tris < 30_000, "{id}/{o} t={t}: {tris} triangles");
}
}
}
}
#[test]
fn hostile_aspects_are_safe() {
for a in [f64::NAN, 0.0, -3.0, 1e9, f64::INFINITY] {
for (id, ..) in TRANSITIONS {
for l in transition_frame(id, "", 0.4, a) {
assert!(l.quad.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
assert!(l.fade.iter().all(|f| (0.0..=1.0).contains(f)));
}
}
}
assert!(near(ss(0.5), 0.5, 1e-12));
}
+3 -3
View File
@@ -1,7 +1,7 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::*;
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
}
for i in 0..h {
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
for &(off, weight, thr, adj) in taps {
let (weight, thr) = (weight as i16, thr as i16);
let start = (ci + off) as usize;
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+21 -1
View File
@@ -1,6 +1,26 @@
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
use crate::spec_tables::*;
use crate::spec_tables::{
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
};
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
/// N cumulative values (the last is 32768) plus the adaptation counter.
+129 -5
View File
@@ -17,7 +17,11 @@ use crate::cdf::Cdfs;
use crate::frame::FrameBuf;
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
use crate::par::Pool;
use crate::spec_tables::*;
use crate::spec_tables::{
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
REFMVS_LIMIT, REFS_PER_FRAME,
};
use crate::state::{FrameShared, FrameState, TileState};
use crate::stats::{DecodeStats, Stage, Timer};
use crate::tile::TileDecoder;
@@ -61,7 +65,7 @@ impl<T: Clone> Slot<T> {
}
fn set(&self, v: Result<T>) {
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*g = Some(v);
self.cv.notify_all();
}
@@ -71,12 +75,12 @@ impl<T: Clone> Slot<T> {
}
fn wait(&self) -> Result<T> {
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
if let Some(v) = g.as_ref() {
return v.clone();
}
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
}
@@ -206,12 +210,35 @@ pub struct Decoder {
/// Frames submitted to the workers and not known to be finished (oldest first).
in_flight: VecDeque<Arc<FrameSlot>>,
max_in_flight: usize,
/// Bytes the picture store may take ([`deckcraft_bitstream::budget`]).
memory_budget: u64,
/// Shown pictures not yet returned, in output order.
out: VecDeque<Output>,
/// pts of the temporal unit being decoded.
pts: i64,
}
/// Whether `pictures` frames of `width` x `height`, padded as `FrameBuf::new` pads them, fit in
/// `budget` bytes.
fn store_fits(width: u32, height: u32, color: &crate::header::ColorConfig, pictures: usize, budget: u64) -> bool {
let shape = deckcraft_bitstream::budget::PictureShape {
width: u64::from(width).div_ceil(128) * 128 + 160,
height: u64::from(height).div_ceil(128) * 128 + 160,
shift_x: u32::from(color.subsampling_x),
shift_y: u32::from(color.subsampling_y),
chroma_planes: if color.mono_chrome { 0 } else { 2 },
bytes_per_sample: 2,
};
shape.fits(pictures as u64, budget)
}
impl Decoder {
/// Cap the bytes of decoded pictures (references, current, in flight) this decoder may hold.
pub fn set_memory_budget(&mut self, bytes: u64) {
self.memory_budget = bytes;
}
}
fn default_threads() -> usize {
#[cfg(all(feature = "threads", not(target_arch = "wasm32")))]
{
@@ -268,6 +295,7 @@ impl Decoder {
workers,
in_flight: VecDeque::new(),
max_in_flight: fthreads + 2,
memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET,
out: VecDeque::new(),
pts: 0,
}
@@ -508,6 +536,12 @@ impl Decoder {
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
return Err(Error::Invalid("frame size"));
}
// The axis cap alone allows 8 GiB pictures: check the whole store (every reference slot,
// the new frame and those in flight, as `FrameBuf::new` pads them) before allocating.
let width = fh.upscaled_width.max(fh.frame_width);
if !store_fits(width, fh.frame_height, &seq.color, NUM_REF_FRAMES + 1 + self.max_in_flight, self.memory_budget) {
return Err(Error::Invalid("frame size exceeds the decoder memory budget"));
}
if !fh.frame_is_intra {
for i in 0..REFS_PER_FRAME {
if self.ref_frames[fh.ref_frame_idx[i]].is_none() {
@@ -555,6 +589,7 @@ impl Decoder {
if tg_end >= num_tiles || tg_start > tg_end {
return Err(Error::Invalid("tile group range"));
}
check_tile_group_start(job.tiles.len(), tg_start)?;
let mut pos = r.byte_pos();
for tile_num in tg_start..=tg_end {
let size = if tile_num == tg_end {
@@ -633,7 +668,9 @@ impl Decoder {
/// Decode a frame job and publish its results.
fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex<DecodeStats>) {
let mut st = DecodeStats::default();
match decode_frame(&job, pool, &mut st) {
// A panic while decoding must still publish an error: the output and every frame that
// references this one wait on these slots.
match guarded(|| decode_frame(&job, pool, &mut st)) {
Ok((rf, pic)) => {
if let Some(ps) = &job.picture {
ps.set(pic.map(Arc::new).ok_or(Error::Invalid("shown frame without picture")));
@@ -652,6 +689,12 @@ fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex<DecodeStats>) {
}
}
/// Run `f`, turning a panic into an error (the decoder's own code never panics on purpose; this
/// keeps a bug from stranding the job's waiters).
fn guarded<R>(f: impl FnOnce() -> Result<R>) -> Result<R> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(Error::Invalid("frame decode panicked")))
}
/// The whole decoding process of one frame: setup, tiles, post-filters, motion vector storage
/// and the output picture.
fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<(Arc<RefFrame>, Option<Picture>)> {
@@ -947,3 +990,84 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
}
true
}
/// Tile groups must arrive in order with no gaps and no overlap: each one starts at the tile
/// after the last one received (`tg_start` semantics, 6.10.1).
fn check_tile_group_start(received: usize, tg_start: usize) -> Result<()> {
if tg_start == received { Ok(()) } else { Err(Error::Invalid("tile group out of order")) }
}
#[cfg(test)]
mod tests {
use super::check_tile_group_start;
#[test]
fn tile_groups_must_be_contiguous() {
assert!(check_tile_group_start(0, 0).is_ok());
assert!(check_tile_group_start(2, 2).is_ok());
// duplicate coverage
assert!(check_tile_group_start(2, 0).is_err());
assert!(check_tile_group_start(2, 1).is_err());
// skipped tiles
assert!(check_tile_group_start(0, 1).is_err());
assert!(check_tile_group_start(2, 3).is_err());
}
}
#[cfg(test)]
mod guard_tests {
use super::*;
#[test]
fn a_panicking_job_still_publishes_an_error_to_its_waiters() {
let slot: Arc<Slot<u32>> = Slot::new();
let picture: Arc<Slot<u32>> = Slot::new();
// Waiters: the output and a dependent frame, on their own threads.
let (tx, rx) = std::sync::mpsc::channel();
for s in [slot.clone(), picture.clone()] {
let tx = tx.clone();
std::thread::spawn(move || {
let _ = tx.send(s.wait().is_err());
});
}
let (s, p) = (slot.clone(), picture.clone());
std::thread::spawn(move || {
let r: Result<u32> = guarded(|| panic!("hostile frame"));
p.set(r.clone());
s.set(r);
});
for _ in 0..2 {
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(10)), Ok(true));
}
assert_eq!(guarded(|| Ok(5)).ok(), Some(5));
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
fn color(mono: bool) -> crate::header::ColorConfig {
crate::header::ColorConfig { mono_chrome: mono, subsampling_x: 1, subsampling_y: 1, ..Default::default() }
}
#[test]
fn the_axis_maximum_is_over_budget_and_4k_is_not() {
let refs = NUM_REF_FRAMES + 1 + 4;
assert!(!store_fits(65536, 65536, &color(false), refs, DECODE_MEMORY_BUDGET));
assert!(!store_fits(65536, 65536, &color(true), 1, DECODE_MEMORY_BUDGET));
assert!(store_fits(3840, 2160, &color(false), refs, DECODE_MEMORY_BUDGET));
assert!(store_fits(1920, 1080, &color(false), refs, DECODE_MEMORY_BUDGET));
}
#[test]
fn exact_budget_boundary_and_reference_count() {
// 8x8 pads to 288x288: luma 82944 + chroma 2 * 20736 samples, 2 bytes each.
let one = (288 * 288 + 2 * 144 * 144) * 2;
assert!(store_fits(8, 8, &color(false), 9, 9 * one));
assert!(!store_fits(8, 8, &color(false), 9, 9 * one - 1));
assert!(!store_fits(8, 8, &color(false), 10, 9 * one));
assert!(store_fits(8, 8, &color(true), 9, 9 * 288 * 288 * 2));
}
}
+3 -3
View File
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
fn zeroed(len: usize) -> Vec<u16> {
if len >= POOL_MIN_LEN {
let found = {
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
};
if let Some(mut v) = found {
@@ -52,8 +52,8 @@ impl Drop for Plane {
fn drop(&mut self) {
if self.data.capacity() >= POOL_MIN_LEN {
let v = std::mem::take(&mut self.data);
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let kept: usize = pool.iter().map(Vec::capacity).sum();
if kept + v.capacity() <= POOL_MAX_SAMPLES {
pool.push(v);
}
+50 -17
View File
@@ -5,6 +5,39 @@ use crate::header::{FilmGrainParams, SequenceHeader};
use crate::spec_tables::{GAUSSIAN_SEQUENCE, MC_IDENTITY};
#[inline]
/// One plane's scaling lookup (7.18.3.4) from its `n` points. The header rejects points whose
/// values don't strictly increase; a pair that still doesn't (defence in depth) is skipped
/// instead of dividing by zero. No points: all zero.
fn scaling_lut(n: usize, xs: &[u8], ys: &[u8]) -> [i32; 256] {
let mut l = [0i32; 256];
let n = n.min(xs.len()).min(ys.len());
let (Some(&x0), Some(&y0)) = (xs.first(), ys.first()) else { return l };
if n == 0 {
return l;
}
for v in l.iter_mut().take(x0 as usize) {
*v = y0 as i32;
}
for i in 0..n - 1 {
let (xa, xb, ya, yb) = (xs[i] as i32, xs[i + 1] as i32, ys[i] as i32, ys[i + 1] as i32);
let dx = xb - xa;
if dx <= 0 {
continue;
}
let delta = (yb - ya) * ((65536 + (dx >> 1)) / dx);
for x in 0..dx {
if let Some(v) = l.get_mut((xa + x) as usize) {
*v = ya + ((x * delta + 32768) >> 16);
}
}
}
let (xl, yl) = (xs[n - 1], ys[n - 1]);
for v in l.iter_mut().skip(xl as usize) {
*v = yl as i32;
}
l
}
fn round2(x: i32, n: u32) -> i32 {
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
}
@@ -124,23 +157,7 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
} else {
(g.num_cr_points, &g.point_cr_value, &g.point_cr_scaling)
};
if n == 0 {
continue;
}
for v in l.iter_mut().take(xs[0] as usize) {
*v = ys[0] as i32;
}
for i in 0..n - 1 {
let dy = ys[i + 1] as i32 - ys[i] as i32;
let dx = xs[i + 1] as i32 - xs[i] as i32;
let delta = dy * ((65536 + (dx >> 1)) / dx);
for x in 0..dx {
l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16);
}
}
for v in l.iter_mut().skip(xs[n - 1] as usize) {
*v = ys[n - 1] as i32;
}
*l = scaling_lut(n, xs, ys);
}
let scale_lut = |plane: usize, index: i32| -> i32 {
let sh = bd - 8;
@@ -274,3 +291,19 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaling_lut_interpolates_and_tolerates_duplicate_points() {
let l = scaling_lut(2, &[0, 255], &[0, 255]);
assert_eq!((l[0], l[128], l[255]), (0, 128, 255));
// Duplicate and descending x values don't divide by zero.
let l = scaling_lut(4, &[10, 10, 5, 200], &[20, 40, 60, 80]);
assert_eq!(l[0], 20);
assert_eq!(l[255], 80);
assert_eq!(scaling_lut(0, &[], &[]), [0; 256]);
}
}
+31 -1
View File
@@ -1,7 +1,14 @@
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
use crate::bits::BitReader;
use crate::spec_tables::*;
use crate::spec_tables::{
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
};
use crate::{Error, Result};
pub const EIGHTTAP: u8 = 0;
@@ -1256,6 +1263,7 @@ impl FrameHeader {
g.point_y_value[i] = r.f(8)? as u8;
g.point_y_scaling[i] = r.f(8)? as u8;
}
check_increasing(&g.point_y_value[..g.num_y_points], "point_y_value")?;
g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? };
if seq.color.mono_chrome
|| g.chroma_scaling_from_luma
@@ -1272,6 +1280,7 @@ impl FrameHeader {
g.point_cb_value[i] = r.f(8)? as u8;
g.point_cb_scaling[i] = r.f(8)? as u8;
}
check_increasing(&g.point_cb_value[..g.num_cb_points], "point_cb_value")?;
g.num_cr_points = r.f(4)? as usize;
if g.num_cr_points > 10 {
return Err(Error::Invalid("num_cr_points"));
@@ -1280,6 +1289,7 @@ impl FrameHeader {
g.point_cr_value[i] = r.f(8)? as u8;
g.point_cr_scaling[i] = r.f(8)? as u8;
}
check_increasing(&g.point_cr_value[..g.num_cr_points], "point_cr_value")?;
}
g.grain_scaling_minus_8 = r.f(2)?;
g.ar_coeff_lag = r.f(2)?;
@@ -1367,3 +1377,23 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result<i32> {
}
}
}
/// Film-grain scaling point values must strictly increase (AV1 spec 6.8.20); a duplicate would
/// divide by zero when the scaling lookup is built.
fn check_increasing(values: &[u8], what: &'static str) -> Result<()> {
if values.windows(2).all(|w| w[0] < w[1]) { Ok(()) } else { Err(Error::Invalid(what)) }
}
#[cfg(test)]
mod grain_point_tests {
use super::*;
#[test]
fn scaling_points_must_strictly_increase() {
assert!(check_increasing(&[], "p").is_ok());
assert!(check_increasing(&[7], "p").is_ok());
assert!(check_increasing(&[0, 10, 255], "p").is_ok());
assert!(matches!(check_increasing(&[0, 10, 10], "point_y_value"), Err(Error::Invalid("point_y_value"))));
assert!(check_increasing(&[20, 10], "p").is_err());
}
}
+12 -3
View File
@@ -5,7 +5,16 @@ use std::sync::OnceLock;
use crate::frame::Plane;
use crate::mvpred::round2_signed64;
use crate::spec_tables::*;
use crate::spec_tables::{
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
};
use crate::tile::TileDecoder;
const PRED_STRIDE: usize = 128;
@@ -678,7 +687,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
let mut acc = [half; W];
for t in 0..8 {
let f = hf[t] as i32;
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
@@ -699,7 +708,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
let mut acc = [half; W];
for t in 0..8 {
let f = vf[t] as i32;
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+8 -1
View File
@@ -3,7 +3,14 @@
use crate::Result;
use crate::header::{EIGHTTAP, SWITCHABLE};
use crate::mvpred::MvP;
use crate::spec_tables::*;
use crate::spec_tables::{
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
};
use crate::tile::TileDecoder;
fn block_width(bs: usize) -> usize {
+5 -1
View File
@@ -1,7 +1,11 @@
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
use crate::frame::Plane;
use crate::spec_tables::*;
use crate::spec_tables::{
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
SMOOTH_V_PRED, V_PRED,
};
use crate::transform::round2;
/// Inputs of the intra prediction process that come from the block.
+4 -1
View File
@@ -1,7 +1,10 @@
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
//! and find_warp_samples.
use crate::spec_tables::*;
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
};
use crate::tile::TileDecoder;
pub(crate) type MvP = [i32; 2];
+4 -1
View File
@@ -1,7 +1,10 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::spec_tables::*;
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
TX_WIDTH,
};
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
+5 -1
View File
@@ -1,7 +1,11 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::*;
use crate::spec_tables::{
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
UPSCALE_FILTER,
};
use crate::state::FrameState;
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
+1 -1
View File
@@ -9,7 +9,7 @@
use crate::decoder::RefFrame;
use crate::frame::{FrameBuf, MiInfo};
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
use crate::spec_tables::*;
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
+23 -5
View File
@@ -6,7 +6,27 @@ use crate::cdf::Cdfs;
use crate::frame::{MiInfo, Mv};
use crate::header::{FrameHeader, NONE, SequenceHeader};
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
use crate::spec_tables::*;
use crate::spec_tables::{
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
V_DCT, V_FLIPADST, V_PRED,
};
use crate::state::{FrameShared, TileState};
use crate::symbol::SymbolDecoder;
use crate::transform::inverse_transform_2d;
@@ -2222,18 +2242,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
if diff <= 2 * r {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
diff
} else {
if diff <= 2 * (max - r - 1) {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
max - (diff + 1)
}
+51 -4
View File
@@ -1,6 +1,9 @@
//! Inverse transforms (spec 7.13), implemented step by step as specified.
use crate::spec_tables::*;
use crate::spec_tables::{
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
};
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i32 {
@@ -162,6 +165,15 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..len {
t[i] = copy[brev(n, i)];
}
inverse_dct_stage1(t, n, r);
inverse_dct_stage2(t, n, r);
inverse_dct_stage3(t, n, r);
inverse_dct_stage4(t, n, r);
inverse_dct_stage5(t, n, r);
inverse_dct_stage6(t, n, r);
}
fn inverse_dct_stage1<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n == 6 {
for i in 0..16 {
bf(t, 32 + i, 63 - i, 63 - 4 * brev(4, i) as i32, false);
@@ -187,6 +199,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r);
}
}
}
fn inverse_dct_stage2<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n == 6 {
for i in 0..4 {
for j in 0..2 {
@@ -218,6 +233,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage3<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..2 {
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
}
@@ -245,6 +263,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage4<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..2 {
hd(t, i, 3 - i, false, r);
}
@@ -270,6 +291,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage5<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n >= 3 {
for i in 0..4 {
hd(t, i, 7 - i, false, r);
@@ -297,6 +321,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
hd(t, i, 15 - i, false, r);
}
}
}
fn inverse_dct_stage6<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n >= 5 {
for i in 0..4 {
bf(t, 27 - i, 20 + i, 32, true);
@@ -528,10 +555,9 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
let col_clamp = (bit_depth + 6).max(16);
let rk = row_kind(tx_type);
let ck = col_kind(tx_type);
let mut t = [0i32; 64];
let rect2 = log2w.abs_diff(log2h) == 1;
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
if !lossless && bit_depth <= 10 {
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
if w >= 8 && h >= 8 {
transform_lanes::<8>(dequant, residual, &p);
} else {
@@ -539,6 +565,13 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
}
return;
}
scalar_row_pass(dequant, residual, &p, lossless);
scalar_col_pass(residual, &p, lossless);
}
fn scalar_row_pass(dequant: &[i32], residual: &mut [i32], p: &Tx2d, lossless: bool) {
let (w, h, log2w, row_shift, row_clamp, col_clamp, rk, rect2) = (p.w, p.h, p.log2w, p.row_shift, p.row_clamp, p.col_clamp, p.rk, p.rect2);
let mut t = [0i32; 64];
let in_w = w.min(32);
for i in 0..h {
// Every 1D transform maps an all-zero row to zeros (each step is linear with
@@ -569,6 +602,11 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
residual[i * w + j] = round2(t[j], row_shift).clamp(lo, hi);
}
}
}
fn scalar_col_pass(residual: &mut [i32], p: &Tx2d, lossless: bool) {
let (w, h, log2h, col_shift, col_clamp, ck) = (p.w, p.h, p.log2h, p.col_shift, p.col_clamp, p.ck);
let mut t = [0i32; 64];
for j in 0..w {
for i in 0..h {
t[i] = residual[i * w + j];
@@ -614,11 +652,16 @@ fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
/// The 2D inverse transform with `L` rows (row pass) or columns (column pass) at a time;
/// bit-identical to the one-at-a-time process for bit depths up to 10.
fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d) {
let mut t = [[0i32; L]; 64];
lanes_row_pass(dequant, residual, p, &mut t);
lanes_col_pass(residual, p, &mut t);
}
fn lanes_row_pass<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
let (w, h) = (p.w, p.h);
let in_w = w.min(32);
let lo = -(1i32 << (p.col_clamp - 1));
let hi = (1i32 << (p.col_clamp - 1)) - 1;
let mut t = [[0i32; L]; 64];
let mut i0 = 0;
while i0 < h {
// Rows without coefficients (and rows 32..63) transform to zeros.
@@ -646,6 +689,10 @@ fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx
}
i0 += L;
}
}
fn lanes_col_pass<const L: usize>(residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
let (w, h) = (p.w, p.h);
let mut j0 = 0;
while j0 < w {
for i in 0..h {
+1 -3
View File
@@ -9,6 +9,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
[dev-dependencies]
proptest = { workspace = true }
craft-media = { workspace = true, features = ["bitstream"] }
+82
View File
@@ -0,0 +1,82 @@
//! Memory budget for decoded pictures, shared by the video decoders.
//!
//! A per-axis size cap does not bound memory: a decoder holds the current picture, every
//! reference picture and the pictures still being decoded on worker threads. Each decoder checks
//! the whole picture store against [`DECODE_MEMORY_BUDGET`] with checked arithmetic before it
//! allocates for a new picture size, so a hostile header is an error, not an out-of-memory abort.
/// Bytes the decoded pictures of one decoder may take: 4 GiB on 64-bit targets, 1 GiB on 32-bit
/// ones (wasm32 has a 4 GiB address space in all).
pub const DECODE_MEMORY_BUDGET: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
/// The shape of one decoded picture as a decoder allocates it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PictureShape {
/// Allocated luma samples per row and rows (padding included).
pub width: u64,
pub height: u64,
/// Chroma subsampling shifts (0 or 1); ignored when `chroma_planes` is 0.
pub shift_x: u32,
pub shift_y: u32,
/// Chroma planes (0 for monochrome, 2 otherwise).
pub chroma_planes: u64,
/// Bytes per sample (1 for 8-bit buffers, 2 for 16-bit ones).
pub bytes_per_sample: u64,
}
impl PictureShape {
/// Bytes of one picture, `None` if it does not fit in a `u64`.
pub fn bytes(&self) -> Option<u64> {
let luma = self.width.checked_mul(self.height)?;
let cw = self.width.div_ceil(1 << self.shift_x.min(1));
let ch = self.height.div_ceil(1 << self.shift_y.min(1));
let chroma = cw.checked_mul(ch)?.checked_mul(self.chroma_planes)?;
luma.checked_add(chroma)?.checked_mul(self.bytes_per_sample)
}
/// Bytes of `pictures` such pictures, `None` on overflow.
pub fn store_bytes(&self, pictures: u64) -> Option<u64> {
self.bytes()?.checked_mul(pictures)
}
/// Whether `pictures` such pictures fit in `budget` bytes.
pub fn fits(&self, pictures: u64, budget: u64) -> bool {
self.store_bytes(pictures).is_some_and(|b| b <= budget)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shape(w: u64, h: u64) -> PictureShape {
PictureShape { width: w, height: h, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 2 }
}
#[test]
fn bytes_count_luma_and_subsampled_chroma() {
assert_eq!(shape(4, 4).bytes(), Some((16 + 2 * 4) * 2));
// Odd sizes round chroma up.
assert_eq!(shape(3, 3).bytes(), Some((9 + 2 * 4) * 2));
let mono = PictureShape { chroma_planes: 0, bytes_per_sample: 1, ..shape(10, 10) };
assert_eq!(mono.bytes(), Some(100));
let full = PictureShape { shift_x: 0, shift_y: 0, ..shape(10, 10) };
assert_eq!(full.bytes(), Some(600));
}
#[test]
fn exact_budget_boundary() {
let s = shape(4, 4); // 48 bytes
assert!(s.fits(10, 480));
assert!(!s.fits(10, 479));
assert!(!s.fits(11, 480));
}
#[test]
fn overflow_is_never_within_budget() {
let huge = shape(u64::MAX / 2, 3);
assert_eq!(huge.bytes(), None);
assert!(!huge.fits(1, u64::MAX));
assert!(!shape(1 << 31, 1 << 31).fits(u64::MAX, u64::MAX));
}
}
+5 -442
View File
@@ -1,451 +1,14 @@
//! Bit-level reading and writing shared by DeckCraft's codec crates (ported from FilmCraft).
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
//!
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
//! - [`budget`]: the memory budget video decoders check their picture store against.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::fmt;
pub use craft_media::bitstream::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitError {
/// Tried to read past the end of the buffer.
Eof,
/// An Exp-Golomb code longer than 32 leading zeros.
InvalidExpGolomb,
}
impl fmt::Display for BitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BitError::Eof => f.write_str("unexpected end of bitstream"),
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
}
}
}
impl std::error::Error for BitError {}
pub type Result<T> = std::result::Result<T, BitError>;
/// MSB-first bit reader over a byte slice.
#[derive(Clone)]
pub struct BitReader<'a> {
data: &'a [u8],
/// Position in bits.
pos: usize,
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn data(&self) -> &'a [u8] {
self.data
}
/// Bit position from the start.
pub fn position(&self) -> usize {
self.pos
}
pub fn bits_left(&self) -> usize {
(self.data.len() * 8).saturating_sub(self.pos)
}
pub fn is_byte_aligned(&self) -> bool {
self.pos.is_multiple_of(8)
}
pub fn byte_align(&mut self) {
self.pos = self.pos.div_ceil(8) * 8;
}
/// Byte offset of the current position (rounded down).
pub fn byte_pos(&self) -> usize {
self.pos / 8
}
pub fn seek_bits(&mut self, pos: usize) {
self.pos = pos;
}
/// Peek up to 32 bits without consuming (zero-padded past the end).
#[inline]
pub fn peek(&self, n: u32) -> u32 {
debug_assert!(n <= 32);
if n == 0 {
return 0;
}
let byte = self.pos / 8;
let shift = self.pos % 8;
let mut v: u64 = 0;
for i in 0..5 {
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
}
// v holds 40 bits starting at `byte`
((v << (24 + shift)) >> (64 - n)) as u32
}
#[inline]
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
if n as usize > self.bits_left() {
return Err(BitError::Eof);
}
let v = self.peek(n);
self.pos += n as usize;
Ok(v)
}
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
if n <= 32 {
return self.read_bits(n).map(u64::from);
}
let hi = self.read_bits(n - 32)? as u64;
let lo = self.read_bits(32)? as u64;
Ok((hi << 32) | lo)
}
#[inline]
pub fn read_bit(&mut self) -> Result<bool> {
self.read_bits(1).map(|b| b == 1)
}
pub fn read_flag(&mut self) -> Result<bool> {
self.read_bit()
}
pub fn skip(&mut self, n: usize) -> Result<()> {
if n > self.bits_left() {
return Err(BitError::Eof);
}
self.pos += n;
Ok(())
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
if zeros > 32 {
return Err(BitError::InvalidExpGolomb);
}
}
if zeros == 0 {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
}
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
/// hostile value near `u32::MAX`.
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
pub fn read_te(&mut self, max: u32) -> Result<u32> {
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
}
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
pub fn more_rbsp_data(&self) -> bool {
let total = self.data.len() * 8;
if self.pos >= total {
return false;
}
// find last set bit (the rbsp_stop_one_bit)
let mut last = None;
for (i, &b) in self.data.iter().enumerate().rev() {
if b != 0 {
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
break;
}
}
match last {
Some(stop) => self.pos < stop,
None => false,
}
}
}
/// MSB-first bit writer.
#[derive(Default, Clone)]
pub struct BitWriter {
buf: Vec<u8>,
acc: u64,
nbits: u32,
}
impl BitWriter {
pub fn new() -> Self {
Self::default()
}
pub fn write_bits(&mut self, value: u32, n: u32) {
debug_assert!(n <= 32);
if n == 0 {
return;
}
let v = (value as u64) & ((1u64 << n) - 1);
self.acc = (self.acc << n) | v;
self.nbits += n;
while self.nbits >= 8 {
self.nbits -= 8;
self.buf.push((self.acc >> self.nbits) as u8);
}
self.acc &= (1u64 << self.nbits) - 1;
}
pub fn write_bit(&mut self, b: bool) {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
let x = v as u64 + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
self.write_bit(false);
}
if len > 32 {
self.write_bits((x >> 32) as u32, len - 32);
self.write_bits(x as u32, 32);
} else {
self.write_bits(x as u32, len);
}
}
pub fn write_se(&mut self, v: i32) {
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
self.write_ue(k);
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
}
pub fn is_byte_aligned(&self) -> bool {
self.nbits == 0
}
/// Pad with zero bits to a byte boundary.
pub fn align_zero(&mut self) {
if self.nbits > 0 {
self.write_bits(0, 8 - self.nbits);
}
}
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
pub fn rbsp_trailing(&mut self) {
self.write_bit(true);
self.align_zero();
}
pub fn write_bytes(&mut self, bytes: &[u8]) {
if self.nbits == 0 {
self.buf.extend_from_slice(bytes);
} else {
for &b in bytes {
self.write_bits(b as u32, 8);
}
}
}
pub fn finish(mut self) -> Vec<u8> {
self.align_zero();
self.buf
}
pub fn bytes(&self) -> &[u8] {
&self.buf
}
}
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b == 3 {
zeros = 0;
continue;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b <= 3 {
out.push(3);
zeros = 0;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
let mut starts = Vec::new();
let mut i = 0;
while i + 3 <= data.len() {
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
starts.push(i + 3);
i += 3;
} else {
i += 1;
}
}
let mut out = Vec::with_capacity(starts.len());
for (k, &s) in starts.iter().enumerate() {
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
while e > s && data[e - 1] == 0 {
e -= 1;
}
if e > s {
out.push(&data[s..e]);
}
}
out
}
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
if i + length_size > data.len() {
return Err(BitError::Eof);
}
let mut len = 0usize;
for k in 0..length_size {
len = (len << 8) | data[i + k] as usize;
}
i += length_size;
if i + len > data.len() {
return Err(BitError::Eof);
}
out.push(&data[i..i + len]);
i += len;
}
Ok(out)
}
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn read_ue_plus_rejects_overflow() {
let mut w = BitWriter::new();
w.write_ue(7);
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
assert!(r.read_ue_plus(1).is_err());
}
#[test]
fn reads_bits() {
let d = [0b1010_1100, 0b0101_0011];
let mut r = BitReader::new(&d);
assert_eq!(r.read_bits(1).unwrap(), 1);
assert_eq!(r.read_bits(3).unwrap(), 0b010);
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
assert_eq!(r.bits_left(), 4);
assert_eq!(r.read_bits(5), Err(BitError::Eof));
}
#[test]
fn exp_golomb_known() {
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
let mut w = BitWriter::new();
for v in 0..5 {
w.write_ue(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in 0..5 {
assert_eq!(r.read_ue().unwrap(), v);
}
let mut w = BitWriter::new();
for v in [0, 1, -1, 2, -2, 1000, -1000] {
w.write_se(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in [0, 1, -1, 2, -2, 1000, -1000] {
assert_eq!(r.read_se().unwrap(), v);
}
}
#[test]
fn emulation_prevention() {
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
let esc = escape_rbsp(&raw);
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
assert_eq!(unescape_rbsp(&esc), raw);
}
#[test]
fn annexb_split() {
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
let n = annexb_nals(&s);
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
}
#[test]
fn more_rbsp() {
let d = [0b1100_0000];
let mut r = BitReader::new(&d);
assert!(r.more_rbsp_data());
r.read_bit().unwrap();
assert!(!r.more_rbsp_data());
}
#[test]
fn crc() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
proptest! {
#[test]
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
let mut w = BitWriter::new();
for &(v, n) in &vals { w.write_bits(v, n); }
for &u in &ues { w.write_ue(u); }
let b = w.finish();
let mut r = BitReader::new(&b);
for &(v, n) in &vals {
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
}
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
}
#[test]
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
let e = escape_rbsp(&data);
prop_assert_eq!(unescape_rbsp(&e), data);
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
}
}
}
pub mod budget;
+9 -1
View File
@@ -35,7 +35,7 @@ impl Rgba {
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
if !s.is_ascii() {
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
@@ -519,6 +519,14 @@ pub fn preset(name: &str) -> Option<Rgba> {
mod tests {
use super::*;
#[test]
fn from_hex_rejects_signs() {
assert_eq!(Rgba::from_hex("+12345"), None);
assert_eq!(Rgba::from_hex("#+1+2+3"), None);
assert_eq!(Rgba::from_hex("+1+2+3+4"), None);
assert_eq!(Rgba::from_hex("+12"), None);
}
#[test]
fn hex_roundtrip() {
assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
+10
View File
@@ -25,3 +25,13 @@ i_overlay = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
deckcraft-anim = { workspace = true }
zip = { workspace = true }
# The browser clock for comment timestamps (wasm has no std::time::SystemTime).
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
+264
View File
@@ -0,0 +1,264 @@
# DeckCraft's built-in thesaurus: headword|synonym, synonym, ...
# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line;
# a headword may appear on several lines (one per sense).
ability|capability, capacity, skill, competence, talent
able|capable, competent, qualified, skilled
accept|agree to, approve, adopt, embrace
accurate|precise, exact, correct, right
achieve|accomplish, attain, reach, realize, deliver
achievement|accomplishment, success, attainment, milestone
add|include, append, attach, insert
advantage|benefit, edge, strength, asset, upside
affect|influence, impact, change, alter
aim|goal, objective, target, purpose, intent
allow|permit, enable, let, authorize
analyze|examine, study, assess, evaluate, review
answer|reply, response, solution
approach|method, strategy, way, technique, plan
area|region, field, domain, zone, sector
arrange|organize, order, sort, plan
ask|request, inquire, query, question
assess|evaluate, gauge, judge, review, appraise
assist|help, aid, support, back
attempt|try, effort, endeavor, bid
bad|poor, weak, inferior, unfavorable, negative
basic|fundamental, essential, core, elementary, simple
begin|start, commence, launch, initiate, open
benefit|advantage, gain, value, payoff, upside
best|finest, top, leading, optimal, premier
big|large, major, substantial, significant, sizable
boost|increase, raise, lift, improve, strengthen
brief|short, concise, succinct, quick
bright|vivid, brilliant, luminous, clever
build|construct, create, develop, assemble, establish
business|company, firm, enterprise, organization, venture
buy|purchase, acquire, obtain
careful|cautious, thorough, meticulous, attentive
cause|reason, source, origin, driver
change|alter, modify, adjust, revise, transform
change|shift, transition, adjustment, revision
cheap|inexpensive, affordable, low-cost, economical
check|verify, confirm, examine, inspect, review
choice|option, alternative, selection, pick
choose|select, pick, opt for, decide on
clear|plain, obvious, evident, transparent, lucid
client|customer, buyer, account, patron
close|near, nearby, adjacent
close|end, finish, conclude, shut
common|frequent, usual, typical, widespread, shared
company|business, firm, organization, enterprise, corporation
complete|finish, conclude, finalize, accomplish
complete|whole, full, entire, total
complex|complicated, intricate, involved, elaborate
concern|worry, issue, problem, risk
consider|weigh, contemplate, evaluate, think about
continue|proceed, carry on, keep going, persist, maintain
cost|price, expense, charge, outlay
create|make, build, produce, design, generate
critical|crucial, vital, essential, key, decisive
current|present, existing, ongoing, latest, up-to-date
customer|client, buyer, consumer, user
data|information, figures, facts, statistics
decide|determine, choose, resolve, settle
decrease|reduce, lower, cut, decline, drop
define|specify, describe, explain, outline
deliver|provide, supply, bring, hand over, ship
demonstrate|show, prove, illustrate, display
describe|explain, outline, portray, depict
design|plan, layout, blueprint, pattern
design|create, plan, devise, draft
develop|build, create, grow, expand, evolve
difference|distinction, contrast, gap, variation
different|distinct, various, diverse, unlike
difficult|hard, challenging, tough, demanding
discuss|talk about, debate, examine, review
easy|simple, straightforward, effortless, painless
effect|result, impact, outcome, consequence
effective|successful, efficient, productive, powerful
effort|work, attempt, endeavor, exertion
end|finish, conclusion, close, completion
enough|sufficient, adequate, ample
ensure|guarantee, make sure, secure, confirm
error|mistake, fault, flaw, bug, slip
essential|vital, crucial, key, necessary, fundamental
establish|set up, found, create, institute
estimate|forecast, projection, approximation, guess
evaluate|assess, appraise, judge, rate, review
event|occasion, function, happening, incident
example|instance, case, illustration, sample
excellent|outstanding, superb, exceptional, first-rate
expand|grow, extend, enlarge, broaden, scale
expect|anticipate, foresee, predict, await
explain|clarify, describe, illustrate, account for
fail|fall short, miss, flop, break down
fast|quick, rapid, swift, speedy
feature|characteristic, attribute, capability, function
final|last, closing, concluding, ultimate
find|discover, locate, identify, uncover
finish|complete, end, conclude, wrap up
focus|concentrate, center, target, emphasize
focus|emphasis, priority, attention, core
future|upcoming, coming, forthcoming, prospective
gain|increase, rise, improvement, profit
get|obtain, acquire, receive, gain
give|provide, offer, supply, grant
goal|aim, objective, target, purpose, ambition
good|fine, great, strong, solid, positive
great|excellent, outstanding, terrific, remarkable
grow|increase, expand, rise, develop, scale
growth|expansion, increase, development, rise
help|assist, aid, support, enable
help|assistance, aid, support, guidance
high|elevated, tall, top, strong
idea|concept, notion, thought, proposal
identify|recognize, pinpoint, detect, determine
impact|effect, influence, consequence, result
important|significant, key, major, crucial, vital
improve|enhance, upgrade, refine, strengthen, boost
include|contain, cover, incorporate, comprise
increase|raise, boost, grow, expand, lift
increase|rise, growth, gain, jump
information|data, details, facts, knowledge
innovative|inventive, creative, original, novel, pioneering
issue|problem, concern, matter, question
job|task, assignment, role, position
keep|retain, maintain, preserve, hold
key|main, central, critical, essential, principal
large|big, sizable, substantial, major, extensive
last|final, latest, previous, closing
launch|start, introduce, roll out, release, unveil
lead|guide, direct, head, steer, run
learn|discover, find out, master, study
less|fewer, smaller, reduced, lower
little|small, minor, slight, modest
look|appear, seem
look|view, glance, examine, inspect
low|minimal, modest, small, reduced
main|primary, chief, principal, central, key
maintain|keep, preserve, sustain, uphold
major|significant, important, key, principal, big
make|create, build, produce, generate
manage|run, handle, oversee, direct, lead
market|industry, sector, segment, marketplace
measure|gauge, assess, quantify, track
meet|satisfy, fulfil, reach, achieve
meeting|session, conference, discussion, review
method|approach, technique, process, procedure, way
model|framework, template, pattern, design
more|additional, extra, further, added
move|shift, transfer, relocate, transition
need|requirement, demand, necessity
need|require, call for, demand, want
new|fresh, recent, novel, modern, latest
next|following, upcoming, subsequent
number|figure, count, total, quantity, amount
objective|goal, aim, target, purpose
offer|provide, propose, extend, present
old|previous, former, legacy, outdated, existing
open|start, launch, begin, unveil
opportunity|opening, chance, prospect, possibility
option|choice, alternative, possibility, route
organize|arrange, structure, coordinate, order
outcome|result, consequence, effect, end result
overview|summary, outline, synopsis, recap
part|portion, piece, segment, section, component
partner|ally, collaborator, associate, affiliate
people|staff, team, individuals, personnel
plan|strategy, scheme, roadmap, proposal, program
plan|organize, arrange, schedule, prepare
point|aspect, detail, issue, argument
popular|well-liked, widespread, in demand, favored
possible|feasible, achievable, viable, potential
present|show, display, introduce, demonstrate
present|current, existing, ongoing
price|cost, rate, charge, fee
priority|focus, concern, precedence, goal
problem|issue, challenge, difficulty, obstacle
process|procedure, method, workflow, system
produce|make, create, generate, manufacture, yield
product|offering, item, solution, good
profit|earnings, return, gain, margin
progress|advance, headway, development, improvement
project|initiative, program, undertaking, venture
propose|suggest, recommend, put forward, offer
provide|give, supply, deliver, offer
purpose|aim, goal, objective, intent, reason
quality|standard, caliber, excellence, grade
question|query, issue, inquiry
quick|fast, rapid, speedy, swift, prompt
rapid|fast, quick, swift, speedy
reach|achieve, attain, hit, arrive at
reason|cause, rationale, motive, basis
recent|latest, new, current, fresh
recommend|suggest, advise, propose, advocate
reduce|cut, lower, decrease, lessen, trim
region|area, territory, zone, market
relevant|pertinent, applicable, related, appropriate
remove|delete, eliminate, take out, drop
replace|substitute, swap, supersede, change
report|account, summary, review, statement
require|need, demand, call for, necessitate
research|study, investigation, analysis, inquiry
resource|asset, supply, means, capacity
result|outcome, effect, consequence, finding
review|assessment, evaluation, examination, analysis
review|examine, assess, evaluate, check, revisit
revenue|income, sales, earnings, turnover
risk|danger, threat, exposure, hazard
role|part, function, position, responsibility
run|operate, manage, conduct, execute
safe|secure, protected, reliable
same|identical, equal, matching, equivalent
save|keep, store, preserve, conserve
see|view, observe, notice, spot
sell|market, offer, trade, retail
service|support, assistance, offering
share|portion, part, stake, percentage
share|distribute, spread, communicate, pass on
show|demonstrate, display, present, reveal, illustrate
significant|important, notable, considerable, major, meaningful
similar|alike, comparable, related, equivalent
simple|easy, straightforward, plain, basic
size|scale, extent, magnitude, dimensions
small|little, minor, compact, modest
solution|answer, fix, remedy, resolution
solve|resolve, fix, settle, work out
specific|particular, precise, exact, distinct
start|begin, launch, commence, kick off, initiate
step|stage, phase, measure, action
strategy|plan, approach, policy, roadmap, tactic
strong|robust, solid, powerful, sturdy
structure|framework, organization, layout, arrangement
success|achievement, accomplishment, win, triumph
successful|effective, thriving, prosperous, winning
suggest|propose, recommend, advise, imply
summary|overview, recap, synopsis, digest
support|help, back, assist, sustain
support|help, assistance, backing, aid
sure|certain, confident, positive, definite
target|goal, objective, aim
task|job, assignment, duty, activity
team|group, crew, staff, squad, unit
test|trial, check, experiment, evaluation
test|try, check, evaluate, verify
think|believe, consider, reckon, suppose
time|period, duration, while, moment
tool|instrument, utility, device, aid
total|sum, whole, aggregate, overall
trend|pattern, tendency, movement, direction
understand|grasp, comprehend, follow, realize
unique|distinctive, singular, one-of-a-kind, unmatched
update|revise, refresh, upgrade, modernize
update|revision, change, upgrade, news
use|employ, apply, utilize, leverage
useful|helpful, practical, handy, valuable
value|worth, benefit, merit, importance
various|diverse, assorted, different, several
view|opinion, perspective, outlook, position
vision|outlook, aspiration, goal, dream
way|method, approach, manner, means
weak|feeble, fragile, poor, limited
well|effectively, successfully, nicely, properly
work|labor, effort, activity, job
work|function, operate, run, succeed
year|annum, twelve months
+124 -7
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
add
),
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
cmd!(
"animation.timing",
@@ -82,6 +82,51 @@ fn start_of(s: &str) -> AnimStart {
}
}
/// The effect option `given` as one of `allowed`. Besides the stored codes ("b", "lt", "u", …)
/// it takes the direction words people type: "fromBottom", "bottom", "fromTopLeft",
/// "upRight". `None` when it matches none of them: the renderer reads direction codes letter
/// by letter, so an unchecked "fromBottom" would fly in from the right and the bottom.
fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
if allowed.is_empty() || allowed.contains(&given) {
return Some(given.to_string());
}
let low = given.to_ascii_lowercase();
if let Some(o) = allowed.iter().find(|o| o.to_ascii_lowercase() == low) {
return Some(o.to_string());
}
let mut rest = low.strip_prefix("from").unwrap_or(&low).trim_start_matches(['-', '_', ' ']);
// Direction words in order, each as both its edge letter and its arrow letter.
let mut dirs: Vec<(char, char)> = Vec::new();
while !rest.is_empty() {
let words: [(&str, (char, char)); 8] = [
("bottom", ('b', 'd')),
("down", ('b', 'd')),
("top", ('t', 'u')),
("up", ('t', 'u')),
("left", ('l', 'l')),
("right", ('r', 'r')),
("-", ('-', '-')),
("_", ('-', '-')),
];
let (w, d) = words.iter().find(|(w, _)| rest.starts_with(w))?;
if d.0 != '-' {
dirs.push(*d);
}
rest = &rest[w.len()..];
}
if dirs.is_empty() || dirs.len() > 2 {
return None;
}
let mut cands: Vec<String> = Vec::new();
for pick in [|d: &(char, char)| d.0, |d: &(char, char)| d.1] {
let code: String = dirs.iter().map(pick).collect();
let rev: String = code.chars().rev().collect();
cands.push(code);
cands.push(rev);
}
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
}
fn build(p: &Value) -> Result<Animation> {
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
let class = class_of(str_param(p, "class"));
@@ -93,7 +138,12 @@ fn build(p: &Value) -> Result<Animation> {
let mut a = Animation {
class: info.2,
effect: info.0.to_string(),
option: str_param(p, "option").map(String::from).or_else(|| info.5.first().map(|o| o.to_string())).unwrap_or_default(),
option: match str_param(p, "option") {
Some(o) => {
resolve_option(info.5, o).ok_or_else(|| bad("animation", format!("option `{o}` is not one of {:?} for `{}`", info.5, info.0)))?
}
None => info.5.first().map(|o| o.to_string()).unwrap_or_default(),
},
duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4),
delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0),
preset_id: Some(info.3),
@@ -127,7 +177,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
"turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75),
"shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(),
"loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(),
_ => format!("M 0 0 L {dx} {dy} E"),
other => deckcraft_model::anim::preset_path(other).unwrap_or_else(|| format!("M 0 0 L {dx} {dy} E")),
}
}
@@ -270,9 +320,14 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
});
each_target(s, p, |a| {
if let Some(o) = &option {
a.option = o.clone();
if a.class == AnimClass::Path {
a.path = Some(default_path(&a.effect, o));
// Each target takes the option in its own effect's terms; one it has no such
// option for keeps its own.
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
if let Some(o) = resolve_option(allowed, o) {
if a.class == AnimClass::Path {
a.path = Some(default_path(&a.effect, &o));
}
a.option = o;
}
}
if let Some(b) = build_mode {
@@ -305,7 +360,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
let id = id_param(p, "id", "animation.painter")?
.or_else(|| st.selection.shapes.first().copied())
.ok_or_else(|| bad("animation.painter", "select an animated shape first"))?;
let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?;
@@ -355,6 +410,26 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::resolve_option;
#[test]
fn direction_words_resolve_to_codes() {
let fly = &["b", "lb", "l", "lt", "t", "rt", "r", "rb"][..];
assert_eq!(resolve_option(fly, "fromBottom").as_deref(), Some("b"));
assert_eq!(resolve_option(fly, "bottom").as_deref(), Some("b"));
assert_eq!(resolve_option(fly, "fromTopLeft").as_deref(), Some("lt"));
assert_eq!(resolve_option(fly, "fromBottomRight").as_deref(), Some("rb"));
assert_eq!(resolve_option(fly, "lt").as_deref(), Some("lt"));
assert_eq!(resolve_option(fly, "sideways"), None);
assert_eq!(resolve_option(fly, "fromBottomTopLeft"), None);
let wipe = &["r", "l", "u", "d", "ru", "lu", "rd", "ld"][..];
assert_eq!(resolve_option(wipe, "fromBottom").as_deref(), Some("d"));
assert_eq!(resolve_option(wipe, "upRight").as_deref(), Some("ru"));
let split = &["horzIn", "horzOut", "vertIn", "vertOut"][..];
assert_eq!(resolve_option(split, "HorzIn").as_deref(), Some("horzIn"));
assert_eq!(resolve_option(&[], "anything").as_deref(), Some("anything"));
}
use super::*;
#[test]
@@ -450,4 +525,46 @@ mod tests {
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
assert!(s.anim_painter.is_some());
}
fn setup_triggers() -> (Session, [u64; 4]) {
let mut s = Session::with_new();
let mut ids = [0u64; 4];
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
*slot = r.get("id").unwrap().as_u64().unwrap();
}
(s, ids)
}
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
}
#[test]
fn deleting_a_trigger_shape_clears_the_trigger() {
let (mut s, [a, b, c, d]) = setup_triggers();
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
assert_eq!(triggers(&s), vec![id(b), id(c)]);
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
}
#[test]
fn deleting_a_group_clears_triggers_of_its_children() {
let (mut s, [a, b, c, _]) = setup_triggers();
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
}
}
+89 -4
View File
@@ -9,6 +9,18 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"guide.add",
"Add Guide",
["View", "Show", "Guides"],
None,
"{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none",
has_doc,
guide_add
),
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move),
cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete),
cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list),
cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)),
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
@@ -129,7 +141,7 @@ fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
/// A shape stays in the list that holds it, so a group's child stays in its group.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
let id = id_param(p, "id", "arrange.reorder")?.ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
let st = s.doc()?;
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
@@ -153,7 +165,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Bounds of shapes (rotation included).
fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
let mut it = xs.iter().map(Xfrm::bounds);
let first = it.next()?;
Some(it.fold(first, |a, b| a.union(b)))
@@ -177,7 +189,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
let xs: Vec<Xfrm> = members.iter().filter_map(|m| m.xfrm).collect();
let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?;
let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height());
let id = ShapeId(doc.alloc_id());
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("arrange.group", NO_ID_SPACE))?);
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0);
let g = Shape {
id,
@@ -196,7 +208,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A child's xfrm in slide space, given its group.
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
@@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
/// Guides are kept on the slide; a file can hold at most this many.
const MAX_GUIDES: usize = 200;
fn guide_limit(s: &Session, vertical: bool) -> Result<f64> {
let size = s.doc()?.doc.slide_size;
Ok(if vertical { size.width } else { size.height })
}
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "defaults", false) {
if !s.doc()?.doc.guides.is_empty() {
return Ok(json!({"added": 0}));
}
let size = s.doc()?.doc.slide_size;
return s.edit(|doc, _| {
doc.guides = vec![
deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 },
deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 },
];
Ok(json!({"added": 2}))
});
}
let vertical = bool_or(p, "vertical", true);
let limit = guide_limit(s, vertical)?;
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit);
if s.doc()?.doc.guides.len() >= MAX_GUIDES {
return Err(bad("guide.add", "too many guides"));
}
s.edit(|doc, _| {
doc.guides.push(deckcraft_model::DrawGuide { vertical, pos });
Ok(json!({"index": doc.guides.len() - 1}))
})
}
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?;
if i >= s.doc()?.doc.guides.len() {
return Err(bad(cmd, "no such guide"));
}
Ok(i)
}
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.move")?;
let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical);
let limit = guide_limit(s, vertical)?;
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit);
s.edit(|doc, _| {
if let Some(g) = doc.guides.get_mut(i) {
g.pos = pos;
}
Ok(json!({"index": i, "pos": pos}))
})
}
fn guide_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.delete")?;
s.edit(|doc, _| {
if i < doc.guides.len() {
doc.guides.remove(i);
}
ok()
})
}
fn guide_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default())
}
#[cfg(test)]
#[path = "guides_tests.rs"]
mod guides_tests;
+202
View File
@@ -0,0 +1,202 @@
//! Picture Format ▸ Artistic Effects: a picture redrawn as a pencil sketch, blurred, grainy…
//!
//! The effect is applied to the pixels and the result becomes the picture, as PowerPoint does;
//! the original is kept (`PictureFill::artistic`) so the effect can be changed or removed, and
//! both are saved (`a14:imgLayer`). The effects approximate PowerPoint's look.
use deckcraft_model::style::Artistic;
use deckcraft_model::{MediaId, ShapeId, ShapeKind};
use image::{GrayImage, Rgba, RgbaImage};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
/// The effects offered: `a14` element name and label.
pub const EFFECTS: &[(&str, &str)] = &[
("artisticBlur", "Blur"),
("artisticPencilGrayscale", "Pencil Grayscale"),
("artisticLineDrawing", "Line Drawing"),
("artisticFilmGrain", "Film Grain"),
("artisticCutout", "Cutout"),
("artisticGlowEdges", "Glow Edges"),
("artisticPhotocopy", "Photocopy"),
];
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"picture.artisticEffect",
"Artistic Effects",
["Picture Format", "Adjust"],
None,
"{effect: none|artisticBlur|artisticPencilGrayscale|artisticLineDrawing|artisticFilmGrain|artisticCutout|artisticGlowEdges|artisticPhotocopy, ids?}",
has_selection,
artistic_effect
)]
}
/// Largest side processed; bigger pictures are scaled down first.
const MAX_SIDE: u32 = 3000;
fn luma(p: &Rgba<u8>) -> f32 {
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
}
fn gray(img: &RgbaImage) -> GrayImage {
GrayImage::from_fn(img.width(), img.height(), |x, y| image::Luma([luma(img.get_pixel(x, y)).round().clamp(0.0, 255.0) as u8]))
}
/// Sobel edge strength, 0–255.
fn edges(g: &GrayImage) -> GrayImage {
let (w, h) = g.dimensions();
let at = |x: i64, y: i64| g.get_pixel(x.clamp(0, w as i64 - 1) as u32, y.clamp(0, h as i64 - 1) as u32)[0] as f32;
GrayImage::from_fn(w, h, |x, y| {
let (x, y) = (x as i64, y as i64);
let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x - 1, y) - at(x - 1, y + 1);
let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x, y - 1) - at(x + 1, y - 1);
image::Luma([((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 255.0) as u8])
})
}
/// Applies `effect` to `img` (alpha kept).
pub fn apply(effect: &str, img: &RgbaImage) -> Option<RgbaImage> {
let (w, h) = img.dimensions();
let scale = ((w * w + h * h) as f32).sqrt() / 1000.0;
let alpha = |x: u32, y: u32| img.get_pixel(x, y)[3];
let out = match effect {
"artisticBlur" => image::imageops::blur(img, (6.0 * scale).max(1.0)),
"artisticPencilGrayscale" | "artisticLineDrawing" | "artisticPhotocopy" => {
let soft = image::imageops::blur(&gray(img), (0.8 * scale).max(0.5));
let e = edges(&soft);
RgbaImage::from_fn(w, h, |x, y| {
let ev = e.get_pixel(x, y)[0] as f32;
let gv = soft.get_pixel(x, y)[0] as f32;
let v = match effect {
"artisticPencilGrayscale" => 255.0 - ev * 2.0 - (255.0 - gv) * 0.25,
"artisticLineDrawing" => {
if ev > 28.0 {
255.0 - (ev - 28.0) * 3.0
} else {
255.0
}
}
_ => {
if ev > 40.0 || gv < 70.0 {
0.0
} else {
255.0
}
}
};
let v = v.clamp(0.0, 255.0) as u8;
Rgba([v, v, v, alpha(x, y)])
})
}
"artisticFilmGrain" => {
// Deterministic noise, so the same picture gives the same grain.
let mut seed: u32 = 0x9E37_79B9;
RgbaImage::from_fn(w, h, |x, y| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let n = (seed % 61) as f32 - 30.0;
let p = img.get_pixel(x, y);
let c = |v: u8| ((v as f32 - 128.0) * 1.1 + 128.0 + n).clamp(0.0, 255.0) as u8;
Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]])
})
}
"artisticCutout" => {
let soft = image::imageops::blur(img, (1.5 * scale).max(0.5));
RgbaImage::from_fn(w, h, |x, y| {
let p = soft.get_pixel(x, y);
let q = |v: u8| ((v as f32 / 255.0 * 3.0).round() / 3.0 * 255.0) as u8;
Rgba([q(p[0]), q(p[1]), q(p[2]), alpha(x, y)])
})
}
"artisticGlowEdges" => {
let e = edges(&gray(img));
RgbaImage::from_fn(w, h, |x, y| {
let k = (e.get_pixel(x, y)[0] as f32 / 255.0 * 2.5).min(1.0);
let p = img.get_pixel(x, y);
let c = |v: u8| (v as f32 * k + 255.0 * k * 0.3).clamp(0.0, 255.0) as u8;
Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]])
})
}
_ => return None,
};
Some(out)
}
/// Decodes `data`, applies `effect` and encodes a PNG.
fn process(effect: &str, data: &[u8]) -> Result<Vec<u8>> {
let img = image::load_from_memory(data).map_err(|_| bad("picture.artisticEffect", "this picture's format can't be processed"))?;
let img = if img.width().max(img.height()) > MAX_SIDE { img.thumbnail(MAX_SIDE, MAX_SIDE) } else { img };
let out = apply(effect, &img.to_rgba8()).ok_or_else(|| bad("picture.artisticEffect", "unknown effect"))?;
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(out)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|_| bad("picture.artisticEffect", "the processed picture couldn't be stored"))?;
Ok(png)
}
fn artistic_effect(s: &mut Session, p: &Value) -> Result<Value> {
let effect = match str_param(p, "effect") {
Some("none") => None,
Some(e) if EFFECTS.iter().any(|(n, _)| *n == e) => Some(e.to_string()),
_ => return Err(bad("picture.artisticEffect", "`effect` is none or one of artisticBlur, artisticPencilGrayscale, …")),
};
let ids = targets(s, p)?;
let st = s.doc()?;
// (shape, original picture, its bytes and name)
let mut work: Vec<(ShapeId, MediaId, Option<(Vec<u8>, String)>)> = Vec::new();
for id in ids {
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { continue };
let original = fill.artistic.as_ref().map_or(fill.media, |a| a.original);
let item = st.doc.media(original).map(|m| (m.data.to_vec(), m.name.clone()));
work.push((id, original, item));
}
if work.is_empty() {
return Err(bad("picture.artisticEffect", "select a picture"));
}
let mut results: Vec<(ShapeId, MediaId, Option<(String, Vec<u8>)>)> = Vec::new();
for (id, original, item) in work {
let processed = match (&effect, item) {
(None, _) => None,
(Some(e), Some((data, name))) => {
Some((format!("{}-{}.png", name.rsplit_once('.').map_or(name.as_str(), |x| x.0), e), process(e, &data)?))
}
(Some(_), None) => return Err(bad("picture.artisticEffect", "the picture's data is missing")),
};
results.push((id, original, processed));
}
let effect2 = effect.clone();
s.edit(move |doc, sel| {
let mut set: Vec<(ShapeId, MediaId, Option<MediaId>)> = Vec::new();
for (id, original, processed) in &results {
let m = match processed {
Some((name, png)) => Some(doc.add_media(name, "image/png", png.clone()).ok_or_else(|| bad("picture.artisticEffect", NO_ID_SPACE))?),
None => None,
};
set.push((*id, *original, m));
}
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.artisticEffect", "no slide"))?;
for (id, original, m) in set {
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
&& let ShapeKind::Picture { fill } = &mut sh.kind
{
match (m, &effect2) {
(Some(m), Some(e)) => {
fill.media = m;
fill.artistic = Some(Artistic { effect: e.clone(), original });
}
_ => {
fill.media = original;
fill.artistic = None;
}
}
}
}
Ok(())
})?;
Ok(json!({"effect": effect}))
}
+244
View File
@@ -0,0 +1,244 @@
//! Picture Format ▸ Remove Background: a classical matte, no model. The background is what the
//! picture's edges reach through similar colours: a flood fill from every edge pixel, each step
//! close to the pixel before it (gradients and soft shadows) and to the edge colour it started
//! from (so it can't leak into the subject). It turns transparent, with a one-pixel soft edge.
use std::collections::VecDeque;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
/// Larger pictures are refused rather than matted slowly (16 megapixels).
const MAX_PIXELS: u64 = 16_000_000;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"picture.removeBackground",
"Remove Background",
["Picture Format", "Adjust"],
None,
"{tolerance?: 0..100 (how different a colour may be from the edge's, default 20), ids?}",
has_selection,
remove_background
)]
}
fn dist(a: [u8; 4], b: [u8; 4]) -> f64 {
let d = |i: usize| f64::from(a[i]) - f64::from(b[i]);
(d(0) * d(0) + d(1) * d(1) + d(2) * d(2)).sqrt()
}
/// Make the background of `img` transparent. `tolerance` is 0–100. Returns how many pixels
/// became transparent.
pub fn matte(img: &mut image::RgbaImage, tolerance: f64) -> usize {
let (w, h) = (img.width() as usize, img.height() as usize);
if w == 0 || h == 0 {
return 0;
}
// Distances run 0–441 (black to white); the step limit follows gradients, the seed limit
// keeps the fill on the edge's colour.
let seed_tol = tolerance.clamp(0.0, 100.0) * 4.41;
let step_tol = (seed_tol * 0.35).max(6.0);
let px = |img: &image::RgbaImage, i: usize| img.get_pixel((i % w) as u32, (i / w) as u32).0;
// 0: subject, 1: background; the seed colour each background pixel was reached from.
let mut bg = vec![false; w * h];
let mut seed = vec![[0u8; 4]; w * h];
let mut queue = VecDeque::new();
let push_edge = |i: usize, bg: &mut [bool], seed: &mut [[u8; 4]], q: &mut VecDeque<usize>| {
if let Some(b) = bg.get_mut(i)
&& !*b
{
*b = true;
if let Some(s) = seed.get_mut(i) {
*s = px(img, i);
}
q.push_back(i);
}
};
for x in 0..w {
push_edge(x, &mut bg, &mut seed, &mut queue);
push_edge((h - 1) * w + x, &mut bg, &mut seed, &mut queue);
}
for y in 0..h {
push_edge(y * w, &mut bg, &mut seed, &mut queue);
push_edge(y * w + w - 1, &mut bg, &mut seed, &mut queue);
}
while let Some(i) = queue.pop_front() {
let (x, y) = (i % w, i / w);
let here = px(img, i);
let from = seed.get(i).copied().unwrap_or(here);
let mut near = [None; 4];
if x > 0 {
near[0] = Some(i - 1);
}
if x + 1 < w {
near[1] = Some(i + 1);
}
if y > 0 {
near[2] = Some(i - w);
}
if y + 1 < h {
near[3] = Some(i + w);
}
for j in near.into_iter().flatten() {
if bg.get(j).copied().unwrap_or(true) {
continue;
}
let c = px(img, j);
// Transparent pixels are background already.
if c[3] < 16 || (dist(c, here) <= step_tol && dist(c, from) <= seed_tol) {
if let Some(b) = bg.get_mut(j) {
*b = true;
}
if let Some(s) = seed.get_mut(j) {
*s = from;
}
queue.push_back(j);
}
}
}
// A background edge that is the subject's colour throughout (the subject touches the
// edge on every side) is no background: leave such a picture alone.
let n = bg.iter().filter(|b| **b).count();
if n == w * h {
return 0;
}
let is_bg = |x: usize, y: usize| bg.get(y * w + x).copied().unwrap_or(false);
let mut soft = vec![];
for y in 0..h {
for x in 0..w {
if is_bg(x, y) {
continue;
}
let touching =
(x > 0 && is_bg(x - 1, y)) || (x + 1 < w && is_bg(x + 1, y)) || (y > 0 && is_bg(x, y - 1)) || (y + 1 < h && is_bg(x, y + 1));
if touching {
soft.push((x, y));
}
}
}
for (i, b) in bg.iter().enumerate() {
if *b {
img.get_pixel_mut((i % w) as u32, (i / w) as u32).0[3] = 0;
}
}
for (x, y) in soft {
let p = img.get_pixel_mut(x as u32, y as u32);
p.0[3] = (u16::from(p.0[3]) * 2 / 3) as u8;
}
n
}
fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
let tolerance = f64_or(p, "tolerance", 20.0);
let ids = targets(s, p)?;
let mut done: Vec<(ShapeId, String, Vec<u8>)> = vec![];
{
let st = s.doc()?;
for id in &ids {
let Some(sh) = st.shape(*id) else { continue };
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let Some(item) = st.doc.media(fill.media) else { continue };
let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(item.data.as_slice())).with_guessed_format() else { continue };
let Ok((iw, ih)) = reader.into_dimensions() else { continue };
if u64::from(iw) * u64::from(ih) > MAX_PIXELS {
return Err(bad("picture.removeBackground", format!("{iw}×{ih} pixels is too large; compress the picture first")));
}
let Ok(dynimg) = image::load_from_memory(&item.data) else { continue };
let mut img = dynimg.to_rgba8();
if matte(&mut img, tolerance) == 0 {
continue;
}
let mut png = vec![];
if image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
continue;
}
let stem = item.name.rsplit_once('.').map_or(item.name.as_str(), |(a, _)| a);
done.push((*id, format!("{stem}-no-background.png"), png));
}
}
if done.is_empty() {
return Err(bad("picture.removeBackground", "no background found: select a picture whose edges show its background"));
}
let n = done.len();
s.edit(|doc, sel| {
let added: Vec<(ShapeId, deckcraft_model::MediaId)> = done
.into_iter()
.map(|(id, name, png)| Ok((id, doc.add_media(&name, "image/png", png).ok_or_else(|| bad("picture.removeBackground", NO_ID_SPACE))?)))
.collect::<Result<_>>()?;
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.removeBackground", "no slide"))?;
for (id, media) in added {
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, id)
&& let ShapeKind::Picture { fill } = &mut sh.kind
{
fill.media = media;
}
}
Ok(())
})?;
Ok(json!({"pictures": n}))
}
#[cfg(test)]
mod tests {
use super::*;
/// A red disc on a white-to-grey gradient.
fn disc() -> image::RgbaImage {
image::RgbaImage::from_fn(80, 60, |x, y| {
let (dx, dy) = (f64::from(x) - 40.0, f64::from(y) - 30.0);
if dx * dx + dy * dy < 15.0 * 15.0 {
image::Rgba([220, 30, 30, 255])
} else {
let g = 255 - (x / 2) as u8;
image::Rgba([g, g, g, 255])
}
})
}
#[test]
fn matte_clears_the_gradient_and_keeps_the_subject() {
let mut img = disc();
let n = matte(&mut img, 20.0);
assert!(n > 80 * 60 / 2, "{n}");
assert_eq!(img.get_pixel(0, 0).0[3], 0);
assert_eq!(img.get_pixel(79, 59).0[3], 0, "the darker end of the gradient");
assert_eq!(img.get_pixel(40, 30).0, [220, 30, 30, 255]);
// The disc's rim is softened, not cut.
let rim = img.get_pixel(40, 16).0[3];
assert!(rim > 0 && rim < 255 || img.get_pixel(40, 15).0[3] < 255, "{rim}");
// A picture with nothing but subject at its edges is left alone.
let mut flat = image::RgbaImage::from_pixel(10, 10, image::Rgba([10, 200, 10, 255]));
assert_eq!(matte(&mut flat, 20.0), 0);
assert!(flat.pixels().all(|p| p.0[3] == 255));
}
#[test]
fn remove_background_replaces_the_picture_and_undoes() {
let mut png = vec![];
image::DynamicImage::ImageRgba8(disc()).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = Session::with_new();
let r = s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "disc.png"})).unwrap();
let id = r["id"].as_u64().unwrap();
let media_of = |s: &Session| match &s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().kind {
ShapeKind::Picture { fill } => fill.media,
_ => panic!("not a picture"),
};
let before = media_of(&s);
s.execute("picture.removeBackground", &json!({"ids": [id]})).unwrap();
let after = media_of(&s);
assert_ne!(before, after);
let item = s.doc().unwrap().doc.media(after).unwrap().clone();
assert_eq!(item.name, "disc-no-background.png");
let img = image::load_from_memory(&item.data).unwrap().to_rgba8();
assert_eq!(img.get_pixel(0, 0).0[3], 0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(media_of(&s), before);
// Not a picture: refused.
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap();
assert!(s.execute("picture.removeBackground", &json!({})).is_err());
}
}
+73
View File
@@ -0,0 +1,73 @@
//! `slide.colorAt`, the colour the slide is drawn with at a point: what the Eyedropper takes (#367).
use serde_json::{Value, json};
use crate::*;
fn deck() -> Session {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
s
}
fn color_at(s: &mut Session, x: f64, y: f64) -> Value {
s.execute("slide.colorAt", &json!({"x": x, "y": y})).unwrap()
}
#[test]
fn a_point_in_a_shape_gives_its_fill_and_one_beside_it_the_background() {
let mut s = deck();
let inside = color_at(&mut s, 150.0, 150.0);
assert_eq!(inside["color"], "FF0000");
assert_eq!(inside["alpha"], 255);
let beside = color_at(&mut s, 700.0, 450.0);
assert_ne!(beside["color"], "FF0000");
// Just inside and just outside the shape's corner.
assert_eq!(color_at(&mut s, 101.0, 101.0)["color"], "FF0000");
assert_eq!(color_at(&mut s, 99.0, 99.0)["color"], beside["color"]);
// A finer or coarser picture gives the same colour.
for scale in [0.5, 1.0, 4.0] {
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale})).unwrap();
assert_eq!(v["color"], "FF0000", "scale {scale}");
}
// It is a query: the deck is not changed and nothing goes on the undo stack.
let revision = s.doc().unwrap().revision;
color_at(&mut s, 150.0, 150.0);
assert_eq!(s.doc().unwrap().revision, revision);
}
#[test]
fn a_see_through_fill_gives_the_colour_it_is_drawn_with() {
let mut s = deck();
let background = color_at(&mut s, 700.0, 450.0)["color"].as_str().unwrap().to_string();
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
let v = color_at(&mut s, 150.0, 150.0);
let c = v["color"].as_str().unwrap();
assert_ne!(c, "FF0000", "half of the background shows through");
assert_ne!(c, background);
assert_eq!(v["alpha"], 255, "the slide is opaque");
}
#[test]
fn points_outside_the_slide_and_bad_parameters_are_errors() {
let mut s = deck();
for p in [
json!({"x": -1, "y": 10}),
json!({"x": 10, "y": -0.5}),
json!({"x": 1e9, "y": 10}),
json!({"x": 10, "y": 1e9}),
json!({"x": 10}),
json!({"y": 10}),
json!({"x": "10", "y": 10}),
json!({"x": 10, "y": 10, "slide": 99}),
] {
assert!(s.execute("slide.colorAt", &p).is_err(), "{p}");
}
// Odd scales are clamped, not refused or crashed on.
for scale in [0.0, -3.0, 1e9] {
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale}));
assert!(v.is_ok(), "{scale}: {v:?}");
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Picture Format ▸ Adjust ▸ Compress Pictures: downsample pictures to a target resolution for
//! the size they are shown at, and optionally delete their cropped-away areas.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"picture.compress",
"Compress Pictures",
["Picture Format", "Adjust"],
None,
"{ppi?: 330|220|150|96 (default 220), deleteCropped?: bool (default true), all?: bool (every picture in the presentation; default the selected pictures), ids?} → {pictures, before, after}",
has_doc,
compress
)]
}
/// A picture compressed for one shape: new bytes, content type and the crop it now needs.
struct Packed {
bytes: Vec<u8>,
content_type: &'static str,
crop: [f64; 4],
}
/// Whether `crop` cuts something away (and only cuts: negative values pad, and are kept).
fn real_crop(c: &[f64; 4]) -> bool {
c.iter().all(|v| (0.0..1.0).contains(v)) && c.iter().any(|v| *v > 0.0) && c[0] + c[2] < 1.0 && c[1] + c[3] < 1.0
}
/// Largest picture decoded for compression, in pixels (a 61 MP camera frame fits); bigger ones
/// are left as they are.
const MAX_PIXELS: u64 = 64_000_000;
/// Most memory the decoder may allocate for one picture.
const MAX_DECODE_BYTES: u64 = 512 << 20;
/// Decode `data` within [`MAX_PIXELS`] and [`MAX_DECODE_BYTES`]: the size is read from the header
/// first, so a picture claiming to be huge is never decoded.
fn decode(data: &[u8], format: image::ImageFormat) -> Option<image::DynamicImage> {
let (w, h) = image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions().ok()?;
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(w);
limits.max_image_height = Some(h);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
reader.decode().ok()
}
/// Compress `data` for a picture shown `w_pt` × `h_pt` with `crop`, at `ppi`. `None` when the
/// format isn't a raster we re-encode (SVG, EMF, GIF…) or nothing would get smaller.
fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_cropped: bool) -> Option<Packed> {
use image::ImageFormat as F;
let format = image::guess_format(data).ok()?;
if !matches!(format, F::Png | F::Jpeg | F::Bmp | F::Tiff | F::WebP) {
return None;
}
let mut img = decode(data, format)?;
let (iw, ih) = (img.width() as f64, img.height() as f64);
if iw < 1.0 || ih < 1.0 {
return None;
}
let cut = delete_cropped && real_crop(&crop);
let mut new_crop = crop;
// The share of the image that is visible across and down.
let (mut fx, mut fy) = ((1.0 - crop[0] - crop[2]).max(0.01), (1.0 - crop[1] - crop[3]).max(0.01));
if cut {
let x = (crop[0] * iw).floor() as u32;
let y = (crop[1] * ih).floor() as u32;
let w = ((fx * iw).round() as u32).clamp(1, img.width() - x.min(img.width() - 1));
let h = ((fy * ih).round() as u32).clamp(1, img.height() - y.min(img.height() - 1));
img = img.crop_imm(x, y, w, h);
new_crop = [0.0; 4];
(fx, fy) = (1.0, 1.0);
} else if !crop.iter().all(|v| v.is_finite()) {
return None;
}
// Pixels needed for the whole (possibly still cropped) image at `ppi`.
let need_w = (w_pt.abs() / 72.0 * ppi / fx).ceil().max(1.0);
let need_h = (h_pt.abs() / 72.0 * ppi / fy).ceil().max(1.0);
let k = (need_w / img.width() as f64).max(need_h / img.height() as f64);
let resized = k < 1.0;
if resized {
let w = ((img.width() as f64 * k).round() as u32).max(1);
let h = ((img.height() as f64 * k).round() as u32).max(1);
img = img.resize_exact(w, h, image::imageops::FilterType::Lanczos3);
}
if !resized && !cut {
return None;
}
let alpha = img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] < 255);
let mut out = std::io::Cursor::new(Vec::new());
let content_type = if format == F::Jpeg && !alpha {
let rgb = img.to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85).encode_image(&rgb).ok()?;
"image/jpeg"
} else {
img.write_to(&mut out, F::Png).ok()?;
"image/png"
};
let bytes = out.into_inner();
// Never make a picture bigger just to resample it; deleting a crop is worth it regardless.
if bytes.len() >= data.len() && !cut {
return None;
}
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) {
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);
}
}
}
fn compress(s: &mut Session, p: &Value) -> Result<Value> {
let ppi = f64_param(p, "ppi").unwrap_or(220.0);
if !ppi.is_finite() || !(36.0..=1200.0).contains(&ppi) {
return Err(bad("picture.compress", "`ppi` must be between 36 and 1200 (PowerPoint offers 330, 220, 150 and 96)"));
}
let delete_cropped = bool_or(p, "deleteCropped", true);
let all = bool_or(p, "all", false);
let ids: HashSet<ShapeId> = if all { HashSet::new() } else { targets(s, p)?.into_iter().collect() };
if !all && ids.is_empty() {
return Err(bad("picture.compress", "select a picture, or pass all: true"));
}
let current = s.doc()?.selection.slide;
s.edit(|doc, _| {
let want = |id: ShapeId| all || ids.contains(&id);
let mut before = 0usize;
let mut after = 0usize;
let mut count = 0usize;
let mut changed: Vec<MediaId> = vec![];
// The same picture at the same size and crop compresses once.
let mut cache: HashMap<(u32, [u64; 4], u64, u64), Option<(MediaId, [f64; 4], usize)>> = HashMap::new();
let range: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![current] };
for i in range {
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);
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));
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());
let hit = match cache.get(&key) {
Some(v) => *v,
None => {
let packed = doc.media(media).filter(|m| !m.data.is_empty()).and_then(|m| {
let old = m.data.len();
pack(&m.data, crop, w, h, ppi, delete_cropped).map(|pk| (pk, old, m.name.clone()))
});
let v = match packed {
Some((pk, old, name)) => {
before += old;
after += pk.bytes.len();
let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or(name);
let ext = if pk.content_type == "image/jpeg" { "jpg" } else { "png" };
let id = doc
.add_media(&format!("{stem}.{ext}"), pk.content_type, pk.bytes)
.ok_or_else(|| bad("picture.compress", NO_ID_SPACE))?;
Some((id, pk.crop, old))
}
None => None,
};
cache.insert(key, v);
v
}
};
if let Some((id, new_crop, _)) = hit {
changed.push(media);
fill.media = id;
fill.crop = new_crop;
count += 1;
touched = true;
}
}
if touched && let Some(sl) = doc.slides.get_mut(i) {
*sl = Arc::new(slide);
}
}
// Drop the originals nothing uses any more, so the saved file actually shrinks.
if !changed.is_empty() {
// Typed walk of every reference (artistic originals, fills, posters, sounds, fonts…).
let used: HashSet<MediaId> = crate::media_refs::presentation_media(doc).into_iter().collect();
doc.media.retain(|m| !changed.contains(&m.id) || used.contains(&m.id));
}
Ok(json!({"pictures": count, "before": before, "after": after, "ppi": ppi}))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 7 % 256) as u8, (y * 5 % 256) as u8, ((x ^ y) % 256) as u8]));
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn pack_downsamples_to_the_shown_size() {
// 2000 px shown at 1 inch: 150 ppi needs 150 px.
let pk = pack(&png(2000, 1000), [0.0; 4], 72.0, 36.0, 150.0, true).unwrap();
let img = image::load_from_memory(&pk.bytes).unwrap();
assert_eq!((img.width(), img.height()), (150, 75));
assert_eq!(pk.content_type, "image/png");
// Already small enough: nothing to do.
assert!(pack(&png(100, 50), [0.0; 4], 72.0, 36.0, 150.0, true).is_none());
// Not a raster we re-encode.
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 pack_deletes_cropped_areas_or_keeps_them() {
let src = png(400, 400);
// Left half cropped away, shown 2 in wide at 100 ppi → 200 px of the visible half.
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 144.0, 288.0, 100.0, true).unwrap();
assert_eq!(pk.crop, [0.0; 4]);
let img = image::load_from_memory(&pk.bytes).unwrap();
assert_eq!((img.width(), img.height()), (200, 400));
// Keeping the crop: the whole image stays, scaled so the visible half has its pixels.
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 72.0, 72.0, 100.0, false).unwrap();
assert_eq!(pk.crop, [0.5, 0.0, 0.0, 0.0]);
let img = image::load_from_memory(&pk.bytes).unwrap();
assert_eq!(img.width(), 200);
}
/// A PNG whose header claims `w` × `h` (no pixel data): enough for the size check.
fn png_header(w: u32, h: u32) -> Vec<u8> {
fn crc(bytes: &[u8]) -> u32 {
let mut c = 0xFFFF_FFFFu32;
for b in bytes {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
fn chunk(out: &mut Vec<u8>, kind: &[u8], data: &[u8]) {
let body: Vec<u8> = kind.iter().chain(data).copied().collect();
out.extend((data.len() as u32).to_be_bytes());
out.extend(&body);
out.extend(crc(&body).to_be_bytes());
}
let mut ihdr = w.to_be_bytes().to_vec();
ihdr.extend(h.to_be_bytes());
ihdr.extend([8, 2, 0, 0, 0]);
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &[]);
chunk(&mut out, b"IEND", &[]);
out
}
#[test]
fn huge_pictures_are_not_decoded() {
// 72 MP: over the cap, though within what the decoder would allocate by default.
let data = png_header(9_000, 8_000);
assert_eq!(
image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Png).into_dimensions().ok(),
Some((9_000, 8_000))
);
assert!(decode(&data, image::ImageFormat::Png).is_none());
assert!(pack(&data, [0.0; 4], 72.0, 72.0, 96.0, true).is_none());
}
/// Compressing a picture whose media is also another picture's artistic-effect original must
/// keep that media: the effect needs it to be changed or removed.
#[test]
fn media_still_used_as_an_artistic_original_is_kept() {
use deckcraft_model::style::Artistic;
let mut s = Session::with_new();
let big = png(1200, 600);
let processed = png(1100, 600);
s.edit(|doc, _| {
let original = doc.add_media("original.png", "image/png", big.clone()).expect("media");
let shown = doc.add_media("processed.png", "image/png", processed.clone()).expect("media");
let slide = Arc::make_mut(doc.slides.first_mut().ok_or_else(|| bad("test", "no slide"))?);
let xfrm = Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 36.0));
let with_effect =
PictureFill { media: shown, artistic: Some(Artistic { effect: "artisticBlur".into(), original }), ..Default::default() };
slide.shapes.push(Shape { id: ShapeId(70_001), xfrm, kind: ShapeKind::Picture { fill: with_effect }, ..Default::default() });
slide.shapes.push(Shape {
id: ShapeId(70_002),
xfrm,
kind: ShapeKind::Picture { fill: PictureFill { media: original, ..Default::default() } },
..Default::default()
});
Ok(())
})
.unwrap();
let r = s.execute("picture.compress", &json!({"all": true, "ppi": 96})).unwrap();
assert_eq!(r["pictures"], 2, "{r}");
let doc = &s.doc().unwrap().doc;
// Every reference left in the deck resolves to media that is still there.
for id in crate::media_refs::presentation_media(doc) {
assert!(doc.media(id).is_some(), "{id:?} was deleted while still referenced");
}
assert!(doc.media.iter().any(|m| m.data.as_slice() == big.as_slice()), "the artistic original was deleted");
assert!(!doc.media.iter().any(|m| m.data.as_slice() == processed.as_slice()), "the replaced processed picture is dropped");
}
}
File diff suppressed because it is too large Load Diff
+380 -36
View File
@@ -1,7 +1,9 @@
//! Edit menu: undo/redo, clipboard, duplicate, delete, select all.
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
@@ -12,13 +14,30 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
cmd!(
"edit.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special",
["Home", "Clipboard"],
None,
"{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only",
has_doc,
paste_special
),
cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate),
cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_slide, select_all),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{scope?: slides}", has_slide, select_all),
cmd!(noundo "edit.deselect", "Deselect", [], Some("Escape"), "{}", has_doc, deselect),
cmd!(noundo "edit.select", "Select Objects", [], None, "{ids: [id], add?: bool, toggle?: bool}", has_slide, select),
]
@@ -32,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
st.doc = e.doc;
st.selection = e.selection;
st.interaction = None;
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
st.last_duplicate = None;
st.revision += 1;
Ok(json!({"label": e.label}))
}
@@ -48,7 +69,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
}
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
@@ -58,6 +79,12 @@ fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
out.push(p);
}
}
deckcraft_model::ShapeKind::Model3d { media, poster } => {
out.push(*media);
if let Some(p) = poster {
out.push(*p);
}
}
_ => {}
}
if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill {
@@ -87,6 +114,9 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
let mut mids = vec![];
for sl in &slides {
media_of_shapes(&sl.shapes, &mut mids);
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {
mids.push(pf.media);
}
}
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
@@ -110,56 +140,173 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
media_of_shapes(&shapes, &mut mids);
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");
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() };
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() };
Ok(plain)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// A caret copies nothing; the previous clipboard must not leak into this answer.
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
Ok(json!({"text": t}))
let html = if caret { String::new() } else { copied_html(s) };
Ok(json!({"text": t, "html": html}))
}
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
fn copied_html(s: &Session) -> String {
if let Some(body) = &s.clipboard.text {
return richclip::body_to_html(body);
}
let mut paragraphs = Vec::new();
for sh in &s.clipboard.shapes {
if let Some(t) = &sh.text {
paragraphs.extend(t.paragraphs.clone());
}
}
if paragraphs.is_empty() {
return String::new();
}
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
if caret {
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
return Ok(json!({"text": t, "html": ""}));
}
let html = copied_html(s);
delete(s, p)?;
Ok(json!({"text": t}))
Ok(json!({"text": t, "html": html}))
}
/// Give pasted shapes fresh ids (groups included) and map connector ends.
pub(crate) fn reid(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) {
fn rec(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) {
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
/// attached to a shape that was copied too follow its copy; ends attached to a shape outside the
/// copied set are detached (the connector keeps its geometry), so a copy never stays glued to
/// the original or to an unrelated shape that happens to share an id in another deck.
pub(crate) fn reid(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
fn rec(
cmd: &str,
doc: &mut deckcraft_model::Presentation,
shapes: &mut [Shape],
map: &mut HashMap<ShapeId, ShapeId>,
depth: usize,
) -> Result<()> {
if depth > 64 {
return Ok(());
}
for s in shapes {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
map.insert(s.id, id);
s.id = id;
if let Some(ch) = s.children_mut() {
rec(cmd, doc, ch, map, depth + 1)?;
}
}
Ok(())
}
let mut map = HashMap::new();
rec(cmd, doc, shapes, &mut map, 0)?;
walk_mut(shapes, &mut |s| {
if let deckcraft_model::ShapeKind::Connector { start, end } = &mut s.kind {
for e in [start, end] {
*e = e.and_then(|(id, site)| map.get(&id).map(|n| (*n, site)));
}
}
});
Ok(map)
}
/// Point copied animations at the copies: targets and triggers in `map` follow it; an animation
/// whose target wasn't copied is dropped, and a trigger that wasn't copied is cleared.
pub(crate) fn remap_animations(anims: &mut Vec<deckcraft_model::Animation>, map: &HashMap<ShapeId, ShapeId>) {
anims.retain_mut(|a| {
let Some(n) = map.get(&a.shape) else { return false };
a.shape = *n;
a.trigger = a.trigger.and_then(|t| map.get(&t).copied());
true
});
}
/// Visit every shape mutably, depth-first (groups included, depth-bounded like `walk`).
fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
fn rec(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape), depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
s.id = ShapeId(doc.alloc_id());
f(s);
if let Some(ch) = s.children_mut() {
rec(doc, ch, depth + 1);
rec(ch, f, depth + 1);
}
}
}
rec(doc, shapes, 0);
rec(shapes, f, 0);
}
fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) {
/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).
fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {
if map.is_empty() {
return;
}
let m = |id: &mut MediaId| {
if let Some(n) = map.get(id) {
*id = *n;
}
};
walk_mut(shapes, &mut |s| {
match &mut s.kind {
deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media),
deckcraft_model::ShapeKind::Media(c) => {
m(&mut c.media);
if let Some(p) = c.poster.as_mut() {
m(p);
}
}
deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p),
_ => {}
}
if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill {
m(&mut pf.media);
}
});
}
/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in
/// `doc` by other bytes (another deck) gets a fresh id, and the pasted shapes must use it.
fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) -> Result<HashMap<MediaId, MediaId>> {
let mut map = HashMap::new();
for m in media {
if doc.media(m.id).is_none_or(|x| x.data != m.data) {
if doc.media(m.id).is_none() {
doc.media.push(m.clone());
} else {
// Id clash with different bytes: unusual (other deck); keep ours, add theirs as new.
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
let _ = id;
match doc.media(m.id) {
None => doc.media.push(m.clone()),
Some(x) if x.data == m.data => {}
Some(_) => {
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
map.insert(m.id, id);
}
}
}
Ok(map)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// External plain text (from the system clipboard) wins when given and differs from ours.
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
if let Some(t) = str_param(p, "text")
&& t != s.clipboard.plain
{
if let Some(html) = str_param(p, "html")
&& let Some(body) = richclip::html_to_body(html)
&& richclip::same_words(&body.text(), t)
{
return paste_external_body(s, &body);
}
return paste_plain(s, t);
}
let editing = s.doc()?.selection.text.is_some();
@@ -174,12 +321,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if !clip.slides.is_empty() {
let at = s.doc()?.selection.slide + 1;
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut new_ids = vec![];
for (k, sl) in clip.slides.iter().enumerate() {
let mut sl = sl.clone();
sl.id = SlideId(doc.alloc_id());
reid(doc, &mut sl.shapes);
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
let map = reid("edit.paste", doc, &mut sl.shapes)?;
remap_animations(&mut sl.animations, &map);
remap_media(&mut sl.shapes, &media);
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background
&& let Some(n) = media.get(&pf.media)
{
pf.media = *n;
}
if doc.layout(sl.layout).is_none() {
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
}
@@ -200,9 +354,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
let off = if same_slide { off } else { 0.0 };
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
reid(doc, &mut shapes);
reid("edit.paste", doc, &mut shapes)?;
remap_media(&mut shapes, &media);
for sh in &mut shapes {
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
@@ -228,6 +383,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::paste_body(s, body);
}
let plain = body.text();
let st = s.doc()?;
let size = st.doc.slide_size;
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
let body = body.clone();
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
if let Some(t) = sh.text.as_mut() {
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
*t = body.clone();
t.body.wrap = wrap;
t.body.autofit = autofit;
}
sel.shapes = vec![ShapeId(id)];
sel.text = None;
Ok(json!({"id": id}))
})
}
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::insert_text(s, text, None);
@@ -239,6 +421,113 @@ fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))
}
fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "as").unwrap_or("destinationTheme") {
"destinationTheme" | "useDestinationTheme" => paste(s, &json!({})),
"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);
}
let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes;
s.clipboard = saved;
s.clipboard.pastes = pastes;
r
}
"picture" => paste_picture(s),
other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))),
}
}
/// 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) {
if depth > 256 {
return;
}
match v {
Value::Object(o) => {
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))
{
o.clear();
o.insert("kind".into(), json!("rgb"));
o.insert("rgb".into(), rgb);
return;
}
for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1);
}
}
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) {
Some("mj") => &fonts.major,
Some("mn") => &fonts.minor,
_ => return,
};
let face = match t.get(3..) {
Some("-lt") => &set.latin,
Some("-ea") => &set.ea,
Some("-cs") => &set.cs,
_ => return,
};
if !face.is_empty() {
*t = face.clone();
}
}
_ => {}
}
}
for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked;
}
}
}
/// Paste Special › Picture: the copied shapes drawn into one PNG picture.
fn paste_picture(s: &mut Session) -> Result<Value> {
let mut clip = s.clipboard.clone();
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);
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height());
let gid = ShapeId(u32::MAX - 1);
let one = if clip.shapes.len() == 1 {
let mut sh = clip.shapes[0].clone();
sh.id = gid;
sh
} else {
Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() }
};
slide.shapes = vec![one];
const SCALE: f64 = 2.0;
let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?;
// render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines).
let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE);
let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some());
let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 };
let png = img.to_png();
s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]}))
}
fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone());
paste_plain(s, &t)
@@ -247,15 +536,31 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let off = s.prefs.duplicate_offset;
s.edit(|doc, sel| {
let last = s.doc()?.last_duplicate.clone();
let (new_ids, made) = s.edit(|doc, sel| {
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
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();
reid(doc, &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
.filter(|(slide, target, pairs)| {
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
})
.and_then(|(_, _, pairs)| {
let (src, copy) = pairs.first()?;
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
let d = (b.x - a.x, b.y - a.y);
(d.0.is_finite() && d.1.is_finite()).then_some(d)
})
.unwrap_or((off, off));
reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += off;
x.y += off;
x.x += dx;
x.y += dy;
c.xfrm = Some(x);
if sel.target == crate::Target::Slides {
c.ph = None;
@@ -268,8 +573,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
shapes.extend(copies);
sel.shapes = new_ids.clone();
Ok(json!({"ids": new_ids}))
})
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
Ok((new_ids, made))
})?;
s.doc_mut()?.last_duplicate = Some(made);
Ok(json!({"ids": new_ids}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -285,12 +593,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
let mut before = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
let n = remove_shapes(shapes, &ids);
// Everything that is gone: the given ids plus group descendants and emptied groups.
let mut left = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
let mut gone = ids.clone();
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
// Animations of deleted shapes go too.
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
let sl = Arc::make_mut(sl);
sl.animations.retain(|a| !gone.contains(&a.shape));
// Effects triggered by a deleted shape fall back to the main sequence.
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
a.trigger = None;
}
}
sel.shapes.retain(|i| !ids.contains(i));
sel.text = None;
@@ -323,7 +643,12 @@ fn count(v: &[Shape]) -> usize {
n
}
fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("scope") {
None | Some(Value::Null) => {}
Some(Value::String(v)) if v == "slides" => return select_all_slides(s),
Some(_) => return Err(bad("edit.selectAll", "scope must be \"slides\"")),
}
if s.doc()?.selection.text.is_some() {
return s.execute("text.selectAll", &json!({}));
}
@@ -335,6 +660,22 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"ids": ids}))
}
/// Thumbnail-pane / sorter select all: every slide, hidden ones included. The current
/// slide stays where it is (the canvas must not jump), so this is not `slide.selectSlides`.
fn select_all_slides(s: &mut Session) -> Result<Value> {
if s.doc()?.selection.target != crate::Target::Slides {
return Err(bad("edit.selectAll", "the slide list is not shown while editing a master"));
}
let ids: Vec<SlideId> = s.doc()?.doc.slides.iter().map(|x| x.id).collect();
s.select(|_, sel| {
sel.slides = ids.clone();
sel.shapes.clear();
sel.text = None;
sel.cells = None;
})?;
Ok(json!({"selected": ids}))
}
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
s.painter = None;
s.anim_painter = None;
@@ -353,7 +694,10 @@ fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => id_param(p, "id", "edit.select")?.map(|i| vec![i]).unwrap_or_default(),
};
let add = bool_or(p, "add", false);
let toggle = bool_or(p, "toggle", false);
let valid: Vec<ShapeId> = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect();
+479 -59
View File
@@ -1,6 +1,10 @@
//! File menu: new, open, save, export, close, properties.
use deckcraft_model::{Presentation, defaults};
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_media::MediaInfo;
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
use serde_json::{Value, json};
use super::*;
@@ -8,15 +12,51 @@ use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new),
cmd!(
noundo "file.new",
"New Presentation",
["File"],
Some("Cmd+N"),
"{theme?: name, template?: starter name, size?: [w,h] pt, blank?: bool}",
always,
new
),
cmd!(query "file.templates", "Starter Templates", [], None, "{} → [{name, summary, theme, slides}]", always, templates),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open),
cmd!(
noundo "file.adopt",
"Open",
[],
None,
"{path, token?} (a presentation the host read in the background and staged with Session::stage_loaded; without one, reads path like file.open)",
always,
adopt
),
cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: deckcraft|pptx}", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(
query "file.info",
"Info",
["File"],
None,
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
has_doc,
info
),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(
query "slide.colorAt",
"Color At",
[],
None,
"{slide?: index, x, y (slide units), scale?: px per unit (2)} → {color: \"RRGGBB\", alpha: 0..255, x, y}: the colour drawn at a point of the slide (the Eyedropper)",
has_doc,
color_at
),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
cmd!(
"file.properties",
@@ -30,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
]
@@ -40,60 +80,210 @@ fn theme_named(name: &str) -> Option<deckcraft_model::Theme> {
deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name))
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default();
let size = p
.get("size")
fn slide_size(p: &Value) -> deckcraft_geom::Size {
p.get("size")
.and_then(Value::as_array)
.and_then(|a| Some(deckcraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0)
.unwrap_or(defaults::WIDE);
let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false));
.unwrap_or(defaults::WIDE)
}
fn templates(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({
"templates": crate::templates::starters().iter().map(|t| json!({
"name": t.name,
"summary": t.summary,
"theme": t.theme,
"slides": t.slides.len(),
})).collect::<Vec<_>>(),
}))
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
let template = str_param(p, "template").filter(|t| !t.is_empty());
let theme_name = str_param(p, "theme").filter(|t| !t.is_empty());
if template.is_some() && theme_name.is_some() {
return Err(bad("file.new", "pass `theme` or `template`, not both"));
}
if template.is_some() && bool_or(p, "blank", false) {
return Err(bad("file.new", "a template is not a blank presentation"));
}
let size = slide_size(p);
let (doc, template_name) = if let Some(name) = template {
let doc = crate::templates::build(name, size)?;
let chosen = crate::templates::find(name).map(|t| t.name);
(doc, chosen)
} else {
let theme = theme_name.and_then(theme_named).unwrap_or_default();
(defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)), None)
};
let slides = doc.slides.len();
let title = s.next_untitled();
let i = s.add_document(DocState::new(doc, None, title));
Ok(json!({"document": i}))
let mut out = json!({"document": i, "slides": slides});
if let Some(name) = template_name {
out["template"] = json!(name);
}
Ok(out)
}
/// Recognise and read presentation bytes (native, PPTX).
pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
if deckcraft_format::sniff(bytes) {
return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
open_checked(name, bytes).map(|(p, _)| p)
}
/// What a file lost on opening.
#[derive(Debug, Default, Clone)]
pub struct Damage {
/// Pictures and media a `.deckcraft` file had lost or damaged.
pub media: Vec<String>,
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
pub slides: Vec<String>,
}
impl Damage {
pub fn is_empty(&self) -> bool {
self.media.is_empty() && self.slides.is_empty()
}
}
/// [`open_presentation`], plus what the file had lost or damaged.
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
if deckcraft_format::sniff(bytes) {
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
return Ok((p, Damage { media, slides: vec![] }));
}
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
}
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_pptx::sniff(bytes) {
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
return Ok(p);
probe_clips(&mut p);
return Ok((p, lost_slides));
}
if deckcraft_pptx::is_encrypted(bytes) {
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".txt") || lower.ends_with(".md") {
let text = String::from_utf8_lossy(bytes);
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
deckcraft_format::outline_to_slides(&mut p, &text);
if lower.ends_with(".md") {
deckcraft_format::markdown_to_slides(&mut p, &text);
} else {
deckcraft_format::outline_to_slides(&mut p, &text);
}
if p.slides.is_empty() {
p = defaults::new_presentation(None);
}
return Ok(p);
return Ok((p, vec![]));
}
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
/// store them. Headers only, so opening never decodes a whole audio file.
fn probe_clips(p: &mut Presentation) {
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
if let ShapeKind::Media(m) = &mut s.kind
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
&& let Some(info) = probed
.entry(m.media)
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
{
if m.duration_ms == 0 {
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
}
if m.width == 0
&& m.height == 0
&& let Some(v) = &info.video
{
(m.width, m.height) = (v.width, v.height);
}
}
if let Some(children) = s.children_mut() {
rec(children, media, probed, depth + 1);
}
}
}
let mut probed = HashMap::new();
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
for slide in &mut p.slides {
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
}
}
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
crate::fonts::install_embedded_fonts(&p);
let missing_fonts = crate::fonts::missing_fonts(&p);
let mut st = DocState::new(p, path, title);
st.missing_fonts = missing_fonts;
s.add_document(st)
}
/// A presentation read from disk, not yet added to a session: [`read_presentation_file`] needs no
/// session, so a host can run it on a background thread, hand the result to
/// [`Session::stage_loaded`] and add it with `file.adopt`.
#[derive(Debug)]
pub struct LoadedFile {
pub path: String,
pub doc: Presentation,
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
pub native: bool,
/// What the file had lost or damaged.
pub damaged: Damage,
}
/// Read and parse the presentation at `path`.
#[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);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
}
/// Add a loaded presentation as the active document: the shared end of `file.open` and
/// `file.adopt`. A damaged file opens as an untitled repaired copy, so Save can't overwrite the
/// original with the empty media.
fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
let LoadedFile { path, doc, native, damaged } = f;
let keep_path = native && damaged.is_empty();
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
report_damage(s, &damaged)?;
let st = s.doc()?;
Ok(json!({
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
}))
}
#[cfg(not(target_arch = "wasm32"))]
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = open_presentation(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
let st = s.doc()?;
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
adopt_loaded(s, read_presentation_file(path)?)
}
/// `file.adopt {path, token?}`: add the presentation a host read in the background and staged
/// with [`Session::stage_loaded`]. Without that staged file (a replayed journal, a stale token)
/// it reads `path` like `file.open`, so the journal entry stays replayable.
fn adopt(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.adopt", "missing `path`"))?;
let staged = p.get("token").and_then(Value::as_u64).and_then(|t| s.take_loaded(t)).filter(|f| f.path == path);
match staged {
Some(f) => adopt_loaded(s, f),
#[cfg(not(target_arch = "wasm32"))]
None => adopt_loaded(s, read_presentation_file(path)?),
#[cfg(target_arch = "wasm32")]
None => Err(bad("file.adopt", format!("{path} was not loaded"))),
}
}
#[cfg(target_arch = "wasm32")]
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
@@ -103,53 +293,153 @@ fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Presentation");
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
let doc = open_presentation(name, &data)?;
let (doc, damaged) = open_checked(name, &data)?;
let i = add_opened(s, name, None, doc);
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
report_damage(s, &damaged)?;
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
}
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
if damaged.is_empty() {
return Ok(());
}
let st = s.doc_mut()?;
st.title = format!("{} (Repaired)", st.title);
let mut text = String::new();
let n = damaged.media.len();
if n > 0 {
text = format!(
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
if n == 1 { "" } else { "s" },
if n == 1 { "" } else { "s" },
if n == 1 { "is" } else { "are" },
);
}
let n = damaged.slides.len();
if n > 0 {
text.push_str(&format!(
"{n} slide{} in this file couldn't be read and {} left out. ",
if n == 1 { "" } else { "s" },
if n == 1 { "was" } else { "were" },
));
}
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
s.ui_requests.push(crate::UiRequest::Message { text });
Ok(())
}
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
match format {
"pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())),
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
/// includeHidden, textLayer, frame}`.
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
/// Paper sizes for notes and handouts, for the UI's Print page.
pub use deckcraft_pdf::{PAPERS, paper_size};
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
/// includeHidden, textLayer, frame, paper: letter|a4|legal|a3, orientation: portrait|landscape}` for a presentation of `count` slides. `slides` is a list of
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
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).min(9) as u8 },
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
o.slides = match p.get("slides") {
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
_ => None,
};
o.include_hidden = bool_or(p, "includeHidden", false);
o.text_layer = bool_or(p, "textLayer", true);
o.frame_slides = bool_or(p, "frame", true);
o
let landscape = match str_param(p, "orientation").unwrap_or("portrait") {
"portrait" => false,
"landscape" => true,
other => return Err(bad("file.export", format!("orientation “{other}” isn't portrait or landscape"))),
};
let paper = str_param(p, "paper").unwrap_or("letter");
o.paper = deckcraft_pdf::paper_size(paper, landscape).ok_or_else(|| {
let ids: Vec<&str> = deckcraft_pdf::PAPERS.iter().map(|p| p.0).collect();
bad("file.export", format!("paper “{paper}” isn't one of {}", ids.join(", ")))
})?;
Ok(o)
}
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
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![];
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()),
None => (part, part),
};
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
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) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(invalid());
}
Ok(out)
}
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
}
/// The output extensions [`format_for_path`] maps to a writer of that format.
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
/// The format a file named `path` is written in, or `None` when its extension names no format
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
/// those as native `.deckcraft` bytes under the wrong name.
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
}
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
}
pub fn format_for_path(path: &str) -> &'static str {
let l = path.to_ascii_lowercase();
if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") {
if l.ends_with(".pptx") {
"pptx"
} else if l.ends_with(".potx") {
"potx"
} else if l.ends_with(".ppsx") {
"ppsx"
} else if l.ends_with(".png") {
"png"
} else if l.ends_with(".jpg") || l.ends_with(".jpeg") {
"jpeg"
} else if l.ends_with(".pdf") {
"pdf"
} else if l.ends_with(".gif") {
"gif"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else {
@@ -171,16 +461,8 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
let tmp = format!("{path}.saving");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other("saving to a path is not available in the browser".into()))
crate::io::write_file(path, bytes).map_err(EngineError::Other)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
@@ -210,11 +492,35 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()}))
}
/// `scale` for rendering `doc`'s slides to an image: within 0.01–16, and small enough that the
/// slide fits the renderer's largest picture ([`deckcraft_render::MAX_SIDE`]). Past that the
/// renderer clips, so a 4× export of a 4032 pt slide came out cropped to its first 16,000 pixels.
pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
let scale = if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 };
let longest = doc.slide_size.width.max(doc.slide_size.height).max(1.0);
scale.min(f64::from(deckcraft_render::MAX_SIDE) / longest)
}
/// Render slide `index` (current when absent) to PNG bytes.
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
let img =
deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() });
(img.to_png(), img.width, img.height)
render_png_with(doc, index, scale, edit, false)
}
/// [`render_png`]; `transparent` leaves the slide background out (#784).
pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec<u8>, u32, u32) {
let scale = image_scale(doc, scale);
let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
let img = deckcraft_render::render_slide(doc, index, &opts);
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
}
fn finite_scale(scale: f64) -> f64 {
if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 }
}
fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec<u8> {
let dpi = deckcraft_render::dpi_for_scale(scale);
if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }
}
fn render(s: &mut Session, p: &Value) -> Result<Value> {
@@ -223,15 +529,42 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
if i >= st.doc.slides.len() {
return Err(bad("file.render", format!("no slide {i}")));
}
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
}
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide.colorAt", format!("no slide {i}")));
}
let coord = |key: &str| {
p.get(key)
.and_then(Value::as_f64)
.filter(|v| v.is_finite())
.ok_or_else(|| bad("slide.colorAt", format!("`{key}` (a number, in slide units) is required")))
};
let (x, y) = (coord("x")?, coord("y")?);
let scale = f64_or(p, "scale", 2.0).clamp(0.01, 16.0);
let img = deckcraft_render::render_slide(&st.doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
let (px, py) = (x * scale, y * scale);
let inside = px >= 0.0 && py >= 0.0 && px < f64::from(img.width) && py < f64::from(img.height);
let [r, g, b, a] =
img.straight_at(px as u32, py as u32).filter(|_| inside).ok_or_else(|| bad("slide.colorAt", "the point is outside the slide"))?;
Ok(json!({"color": format!("{r:02X}{g:02X}{b:02X}"), "alpha": a, "x": x, "y": y}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
let st = s.doc()?;
let scale = f64_or(p, "scale", 2.0);
let scale = finite_scale(f64_or(p, "scale", 2.0));
let transparent = bool_or(p, "transparentBackground", false);
if transparent && format != "png" {
return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
}
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec<usize> = match usize_param(p, "slide") {
@@ -239,17 +572,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
None => vec![st.selection.slide],
};
// Every slide checked before any file is written, so a bad index leaves nothing behind.
if let Some(i) = slides.iter().find(|i| **i >= st.doc.slides.len()) {
return Err(bad("file.export", format!("no slide {i}")));
}
let scale = image_scale(&st.doc, scale);
let mut written = vec![];
for (k, i) in slides.iter().enumerate() {
if *i >= st.doc.slides.len() {
return Err(bad("file.export", format!("no slide {i}")));
}
let img = deckcraft_render::render_slide(
&st.doc,
*i,
&deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() },
&deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
);
let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) };
let bytes = picture_bytes(&img, &format, scale);
let out = if slides.len() == 1 {
path.clone()
} else {
@@ -258,24 +593,65 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone());
pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string()
};
write_file(&out, &bytes)?;
// Say which slides made it when one can't be written (disk full part-way).
write_file(&out, &bytes)
.map_err(|e| if written.is_empty() { e } else { EngineError::Other(format!("{e} (slides 1–{k} were written)")) })?;
written.push(out);
}
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = pdf_bytes(&st.doc, p)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
other => {
let bytes = save_bytes(&st.doc, other)?;
"gif" => {
// `slides` may be a range, as for PDF ("1-3,5").
let mut gp = p.clone();
if let Some(text) = p.get("slides").and_then(Value::as_str) {
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
}
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
write_file(&path, &out.bytes)?;
Ok(json!({
"path": path,
"bytes": out.bytes.len(),
"frames": out.frames,
"width": out.width,
"height": out.height,
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
}))
}
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
let bytes = save_bytes(&st.doc, &format)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
other => Err(bad("file.export", format!("unknown format `{other}`"))),
}
}
/// File › Info: what the Info page shows about the active presentation.
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_deref().and_then(crate::io::file_len);
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
"title": st.title(),
"path": st.path,
"format": st.path.as_deref().map(format_for_path),
"dirty": st.is_dirty(),
"slides": doc.slides.len(),
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
"sections": doc.sections.len(),
"bytes": bytes,
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
}))
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
if let Some(i) = s.active_index() {
s.close_document(i);
@@ -367,3 +743,47 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
#[cfg(test)]
#[path = "color_at_tests.rs"]
mod color_at_tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::write_file;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_file_does_not_touch_a_predictable_saving_sibling() {
let dir = scratch("sibling");
let dest = dir.join("deck.deckcraft");
let dest_str = dest.to_str().unwrap();
std::fs::write(&dest, b"old").unwrap();
std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap();
write_file(dest_str, b"new").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious");
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_file_failed_rename_keeps_destination_and_cleans_up() {
let dir = scratch("failed");
let dest = dir.join("target");
std::fs::create_dir(&dest).unwrap();
assert!(write_file(dest.to_str().unwrap(), b"new").is_err());
assert!(dest.is_dir());
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}
+268
View File
@@ -0,0 +1,268 @@
//! Home › Editing: Find and Replace across slide text, table cells and speaker notes.
use std::sync::Arc;
use deckcraft_model::text::RunKind;
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "text.find",
"Find",
["Home", "Editing"],
None,
"{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)",
has_doc,
find
),
cmd!(
"text.replace",
"Replace",
["Home", "Editing"],
None,
"{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}",
has_doc,
replace
),
]
}
/// Where a text body lives on a slide.
#[derive(Clone, Copy, PartialEq, Debug)]
struct Loc {
shape: Option<u64>,
cell: Option<(usize, usize)>,
}
/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes.
fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) {
fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) {
if depth > 64 {
return;
}
for sh in shapes {
let id = Some(u64::from(sh.id.0));
if let Some(t) = &mut sh.text {
f(Loc { shape: id, cell: None }, t);
}
match &mut sh.kind {
ShapeKind::Table(t) => {
for (r, row) in t.rows.iter_mut().enumerate() {
for (c, cell) in row.cells.iter_mut().enumerate() {
f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text);
}
}
}
ShapeKind::Group { children, .. } => rec(children, depth + 1, f),
_ => {}
}
}
}
rec(&mut slide.shapes, 0, f);
f(Loc { shape: None, cell: None }, &mut slide.notes);
}
struct Opts {
pat: Vec<char>,
case: bool,
whole: bool,
}
impl Opts {
fn from(p: &Value, cmd: &str) -> Result<Self> {
let pat: Vec<char> = str_param(p, "text").unwrap_or("").chars().collect();
if pat.is_empty() {
return Err(bad(cmd, "missing `text`"));
}
Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) })
}
fn eq(&self, a: char, b: char) -> bool {
a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase()))
}
/// Non-overlapping matches as char ranges.
fn matches(&self, text: &[char]) -> Vec<(usize, usize)> {
let n = self.pat.len();
let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_');
let mut out = vec![];
let mut i = 0;
while i + n <= text.len() {
let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b))
&& (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n))));
if hit {
out.push((i, i + n));
i += n;
} else {
i += 1;
}
}
out
}
}
fn para_chars(p: &Paragraph) -> Vec<char> {
p.runs.iter().flat_map(|r| r.content().chars()).collect()
}
/// Matches that lie wholly in plain text runs (never inside a field, equation or break).
fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> {
let mut spans = vec![];
let mut off = 0;
for r in &p.runs {
let n = r.char_len();
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
off += n;
}
o.matches(&para_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect()
}
/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting.
fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) {
let mut off = 0;
let mut placed = false;
for r in &mut p.runs {
let chars: Vec<char> = r.content().chars().collect();
let (s, e) = (off, off + chars.len());
off = e;
if e <= a || s >= b {
continue;
}
let keep_head: String = chars[..a.saturating_sub(s)].iter().collect();
let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect();
r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") };
placed = true;
}
if p.runs.len() > 1 {
p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text)));
}
}
fn find(s: &mut Session, p: &Value) -> Result<Value> {
let o = Opts::from(p, "text.find")?;
let st = s.doc()?;
let mut out = vec![];
for (i, sl) in st.doc.slides.iter().enumerate() {
let mut sl = (**sl).clone();
bodies(&mut sl, &mut |loc, t| {
for (pi, para) in t.paragraphs.iter().enumerate() {
for (a, b) in para_matches(para, &o) {
out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b}));
}
}
});
}
Ok(Value::Array(out))
}
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let o = Opts::from(p, "text.replace")?;
let with = str_param(p, "with").unwrap_or("").to_string();
let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> {
let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`")));
let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize)));
Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?))
});
let at = at.transpose()?;
let count = s.edit(|doc, _| {
let mut count = 0;
for (i, slp) in doc.slides.iter_mut().enumerate() {
if at.is_some_and(|a| a.0 != i) {
continue;
}
let mut sl = (**slp).clone();
let mut changed = 0;
bodies(&mut sl, &mut |loc, t| {
if at.is_some_and(|a| a.1 != loc) {
return;
}
for (pi, para) in t.paragraphs.iter_mut().enumerate() {
if at.is_some_and(|a| a.2 != pi) {
continue;
}
for (a, b) in para_matches(para, &o).into_iter().rev() {
if at.is_some_and(|x| x.3 != a) {
continue;
}
splice(para, a, b, &with);
changed += 1;
}
}
});
if changed > 0 {
*slp = Arc::new(sl);
count += changed;
}
}
if count == 0 {
return Err(bad("text.replace", "no matches"));
}
Ok(count)
})?;
Ok(json!({"replaced": count}))
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::{Run, RunProps};
fn opts(t: &str, case: bool, whole: bool) -> Opts {
Opts { pat: t.chars().collect(), case, whole }
}
#[test]
fn matching_honours_case_and_whole_words() {
let t: Vec<char> = "Cat cat category".chars().collect();
assert_eq!(opts("cat", false, false).matches(&t).len(), 3);
assert_eq!(opts("cat", true, false).matches(&t).len(), 2);
assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]);
}
fn text_run(t: &str) -> Run {
Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text }
}
fn br() -> Run {
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }
}
#[test]
fn a_match_never_spans_a_line_break() {
let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() };
assert!(para_matches(&p, &opts("ab", true, false)).is_empty());
}
#[test]
fn offsets_count_a_line_break_as_one_char() {
let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]);
}
#[test]
fn splice_after_a_line_break_keeps_the_break() {
let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
splice(&mut p, 3, 5, "X");
assert_eq!(p.text(), "ab\u{b}X");
assert_eq!(p.runs[1].kind, RunKind::Break);
}
#[test]
fn splice_across_runs_keeps_the_first_runs_format() {
let bold = RunProps { bold: Some(true), ..Default::default() };
let mut p = Paragraph {
runs: vec![
Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text },
Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text },
],
..Default::default()
};
splice(&mut p, 1, 5, "ola");
assert_eq!(para_chars(&p).iter().collect::<String>(), "Hola world");
assert_eq!(p.runs[0].text, "Hola");
assert_eq!(p.runs[0].props, bold);
}
}
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and
//! the PPTX round trip.
use serde_json::json;
use crate::Session;
use crate::tools::{Mods, PointerEvent, PointerKind};
fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap();
}
#[test]
fn add_move_delete_and_undo() {
let mut s = Session::with_new();
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2);
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none");
let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap();
assert_eq!(i, 2);
s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide");
assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err());
assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err());
s.execute("guide.delete", &json!({"index": 0})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
}
#[test]
fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
// Hidden guides can't be grabbed: the press starts a marquee instead.
ev(&mut s, PointerKind::Down, 300.0, 50.0);
ev(&mut s, PointerKind::Up, 300.0, 50.0);
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0);
s.tool.guides_visible = true;
ev(&mut s, PointerKind::Down, 301.0, 50.0);
ev(&mut s, PointerKind::Drag, 350.4, 60.0);
assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging");
ev(&mut s, PointerKind::Up, 350.4, 60.0);
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0);
assert_eq!(s.tool.guide_drag, None);
// A shape dragged near the guide snaps its left edge onto it.
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap();
s.prefs.snap_to_grid = false;
ev(&mut s, PointerKind::Down, 140.0, 230.0);
ev(&mut s, PointerKind::Drag, 200.0, 230.0);
ev(&mut s, PointerKind::Drag, 388.0, 231.0);
ev(&mut s, PointerKind::Up, 388.0, 231.0);
let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap();
assert!((x.x - 350.0).abs() < 0.01, "{x:?}");
// Dropped off the slide: gone, and undo brings it back.
ev(&mut s, PointerKind::Down, 350.0, 10.0);
ev(&mut s, PointerKind::Drag, -40.0, 10.0);
ev(&mut s, PointerKind::Up, -40.0, 10.0);
assert!(s.doc().unwrap().doc.guides.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.len(), 1);
}
#[test]
fn guides_round_trip_through_pptx() {
let mut s = Session::with_new();
s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap();
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
assert_eq!(back.guides, s.doc().unwrap().doc.guides);
let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap();
assert!(none.guides.is_empty());
}
+425
View File
@@ -0,0 +1,425 @@
//! Design Ideas: arrangements of what is on the current slide (picture full-bleed with the title
//! on a band, picture on one half, a framed picture, text-only layouts). An idea moves and
//! resizes the slide's own shapes and crops pictures to their new box; it changes only that
//! slide. Everything is computed here, offline.
use deckcraft_geom::Xfrm;
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Selection, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "design.ideas", "Design Ideas", [], None, "{slide?} → [{id, label}] ideas for the slide", has_slide, ideas_cmd),
cmd!("design.applyIdea", "Apply Design Idea", ["Home", "Designer"], None, "{id, slide?}", has_slide, apply_cmd),
]
}
/// An idea: id and label.
pub type Idea = (&'static str, &'static str);
const PICTURE_IDEAS: [Idea; 4] = [
("fullBleed", "Full-bleed picture, title on a band"),
("pictureLeft", "Picture left, text right"),
("pictureRight", "Picture right, text left"),
("framed", "Framed picture beside the title"),
];
const TEXT_IDEAS: [Idea; 3] =
[("titleColumn", "Title column beside the text"), ("centred", "Centred title over the text"), ("titleBottom", "Text above, title at the foot")];
/// The roles of a slide's top-level shapes, by index.
struct Roles {
title: Option<usize>,
picture: Option<usize>,
text: Vec<usize>,
}
fn roles(shapes: &[Shape]) -> Roles {
let mut r = Roles { title: None, picture: None, text: vec![] };
for (i, s) in shapes.iter().enumerate() {
if s.hidden {
continue;
}
if s.ph.as_ref().is_some_and(|p| p.kind.is_title()) && r.title.is_none() {
r.title = Some(i);
} else if matches!(s.kind, ShapeKind::Picture { .. }) {
// The largest picture leads.
let area = |j: usize| shapes.get(j).and_then(|s| s.xfrm).map(|x| x.w * x.h).unwrap_or(0.0);
if r.picture.is_none_or(|p| area(i) > area(p)) {
r.picture = Some(i);
}
} else if s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()) {
r.text.push(i);
}
}
r
}
/// The ideas for slide `index` (none when it has nothing to arrange).
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
let Some(slide) = doc.slides.get(index) else { return vec![] };
let r = roles(&slide.shapes);
if r.picture.is_some() {
PICTURE_IDEAS.to_vec()
} else if r.title.is_some() && !r.text.is_empty() {
TEXT_IDEAS.to_vec()
} else {
vec![]
}
}
/// Crop a picture whose source has aspect `src` (w/h) to fill a box of aspect `dst`, centred.
fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
if !(src.is_finite() && dst.is_finite() && src > 0.0 && dst > 0.0) {
return [0.0; 4];
}
if src > dst {
let cut = (1.0 - dst / src) / 2.0;
[cut, 0.0, cut, 0.0]
} else {
let cut = (1.0 - src / dst) / 2.0;
[0.0, cut, 0.0, cut]
}
}
/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform
/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen.
fn band_fill() -> Fill {
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274)))
}
/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha).
fn legacy_band_fill() -> Fill {
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))
}
/// Whether `f` is a Full-bleed band, in the current or the legacy form.
fn is_band(f: &Fill) -> bool {
*f == band_fill() || *f == legacy_band_fill()
}
/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes).
pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> {
if !ideas_for(doc, index).iter().any(|i| i.0 == id) {
return Err(bad("design.applyIdea", format!("no idea `{id}` for this slide (see design.ideas)")));
}
let (sw, sh) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
let sel = Selection { slide: index, ..sel.clone() };
let shapes = doc.slides.get(index).map(|s| s.shapes.clone()).ok_or_else(|| bad("design.applyIdea", "no slide"))?;
let r = roles(&shapes);
let boxes: Vec<Xfrm> = shapes.iter().map(|s| xfrm_of(doc, &sel, s)).collect();
let m = sw.min(sh) * 0.06;
let mut place: Vec<(usize, Xfrm)> = vec![];
// Text shapes stacked in a box.
let stack = |b: Xfrm, out: &mut Vec<(usize, Xfrm)>| {
let n = r.text.len().max(1) as f64;
let gap = m * 0.4;
let h = ((b.h - gap * (n - 1.0)) / n).max(1.0);
for (k, i) in r.text.iter().enumerate() {
out.push((*i, Xfrm::new(b.x, b.y + k as f64 * (h + gap), b.w, h)));
}
};
let mut band = false;
match id {
"fullBleed" => {
if let Some(p) = r.picture {
place.push((p, Xfrm::new(0.0, 0.0, sw, sh)));
}
if let Some(t) = r.title {
place.push((t, Xfrm::new(m, sh * 0.68, sw - 2.0 * m, sh * 0.2)));
band = true;
}
stack(Xfrm::new(m, sh * 0.06, sw * 0.45, sh * 0.5), &mut place);
}
"pictureLeft" | "pictureRight" => {
let left = id == "pictureLeft";
let half = sw * 0.5;
let (px, tx) = if left { (0.0, half + m) } else { (half, m) };
if let Some(p) = r.picture {
place.push((p, Xfrm::new(px, 0.0, half, sh)));
}
let tw = half - 2.0 * m;
if let Some(t) = r.title {
place.push((t, Xfrm::new(tx, m, tw, sh * 0.24)));
}
stack(Xfrm::new(tx, m + sh * 0.28, tw, sh - 2.0 * m - sh * 0.28), &mut place);
}
"framed" => {
if let Some(p) = r.picture {
place.push((p, Xfrm::new(sw * 0.42, m * 1.5, sw * 0.58 - m * 1.5, sh - 3.0 * m)));
}
let tw = sw * 0.42 - 2.0 * m;
if let Some(t) = r.title {
place.push((t, Xfrm::new(m, m * 1.5, tw, sh * 0.3)));
}
stack(Xfrm::new(m, m * 1.5 + sh * 0.34, tw, sh - 3.0 * m - sh * 0.34), &mut place);
}
"titleColumn" => {
if let Some(t) = r.title {
place.push((t, Xfrm::new(m, m, sw * 0.32, sh - 2.0 * m)));
}
stack(Xfrm::new(sw * 0.36, m, sw * 0.64 - m, sh - 2.0 * m), &mut place);
}
"centred" => {
if let Some(t) = r.title {
place.push((t, Xfrm::new(sw * 0.1, m, sw * 0.8, sh * 0.26)));
}
stack(Xfrm::new(sw * 0.18, m + sh * 0.3, sw * 0.64, sh - 2.0 * m - sh * 0.3), &mut place);
}
"titleBottom" => {
stack(Xfrm::new(m, m, sw - 2.0 * m, sh * 0.62), &mut place);
if let Some(t) = r.title {
place.push((t, Xfrm::new(m, sh * 0.72, sw - 2.0 * m, sh * 0.2)));
}
}
_ => return Err(bad("design.applyIdea", format!("no idea `{id}`"))),
}
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("design.applyIdea", "no slide"))?;
let slide = std::sync::Arc::make_mut(slide);
for (i, x) in place {
let (Some(s), Some(old)) = (slide.shapes.get_mut(i), boxes.get(i)) else { continue };
if let ShapeKind::Picture { fill } = &mut s.kind {
// The source's aspect, from the current box and crop.
let c = fill.crop;
let (fw, fh) = ((1.0 - c[0] - c[2]).max(0.01), (1.0 - c[1] - c[3]).max(0.01));
let src = (old.w / fw) / (old.h / fh).max(1e-6);
fill.crop = cover_crop(src, x.w / x.h.max(1e-6));
}
s.xfrm = Some(Xfrm { rot: 0.0, ..x });
}
// The picture goes to the back, so text over it stays readable.
if let Some(p) = r.picture
&& p < slide.shapes.len()
{
let pic = slide.shapes.remove(p);
slide.shapes.insert(0, pic);
}
if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
if band {
t.fill = Some(band_fill());
} else if t.fill.as_ref().is_some_and(is_band) {
// The band belongs to full-bleed only: another idea takes it away again.
t.fill = None;
}
}
Ok(())
}
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
}
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
let slide = usize_param(p, "slide");
s.edit(|doc, sel| {
let i = slide.unwrap_or(sel.slide);
apply_idea(doc, sel, i, &id)?;
Ok(json!({"slide": i, "id": id}))
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{PhType, Placeholder, ShapeId};
use super::*;
fn deck(picture: bool) -> Session {
let mut s = Session::with_new();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let slide = Arc::make_mut(&mut doc.slides[0]);
slide.shapes.clear();
let title = Shape {
id: ShapeId(500),
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
text: Some(deckcraft_model::text::TextBody::from_text("Harbour at dawn")),
..Default::default()
};
let body = Shape {
id: ShapeId(501),
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
..Default::default()
};
slide.shapes.push(title);
slide.shapes.push(body);
if picture {
// A 2:1 picture covering most of the slide, over the title.
let pic = Shape {
id: ShapeId(502),
kind: ShapeKind::Picture { fill: PictureFill::default() },
xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 450.0)),
..Default::default()
};
slide.shapes.push(pic);
}
s
}
fn shape(s: &Session, id: u32) -> Shape {
s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.id == ShapeId(id)).cloned().unwrap()
}
fn overlaps(a: Xfrm, b: Xfrm) -> bool {
a.x < b.x + b.w - 0.5 && b.x < a.x + a.w - 0.5 && a.y < b.y + b.h - 0.5 && b.y < a.y + a.h - 0.5
}
#[test]
fn picture_slides_get_layout_ideas_and_text_slides_get_text_ideas() {
let s = deck(true);
let ids: Vec<&str> = ideas_for(&s.doc().unwrap().doc, 0).iter().map(|i| i.0).collect();
assert_eq!(ids, ["fullBleed", "pictureLeft", "pictureRight", "framed"]);
let s = deck(false);
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
assert!(ideas_for(&s.doc().unwrap().doc, 9).is_empty());
}
#[test]
fn picture_left_puts_text_beside_the_picture_cropped_to_fill() {
let mut s = deck(true);
let theme_before = s.doc().unwrap().doc.masters[0].theme.name.clone();
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
let (pic, title, body) = (shape(&s, 502), shape(&s, 500), shape(&s, 501));
let px = pic.xfrm.unwrap();
assert_eq!((px.x, px.y, px.w, px.h), (0.0, 0.0, 480.0, 540.0));
assert!(!overlaps(px, title.xfrm.unwrap()) && !overlaps(px, body.xfrm.unwrap()));
assert!(!overlaps(title.xfrm.unwrap(), body.xfrm.unwrap()));
// 2:1 source into an 8:9 box: cut equally from left and right.
let ShapeKind::Picture { fill } = &pic.kind else { panic!() };
assert!(fill.crop[0] > 0.25 && (fill.crop[0] - fill.crop[2]).abs() < 1e-9 && fill.crop[1] == 0.0, "{:?}", fill.crop);
// Only this slide's arrangement changed, not the theme; undo restores it.
assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, theme_before);
assert_eq!(s.doc().unwrap().doc.slides[0].shapes[0].id, ShapeId(502), "picture at the back");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shape(&s, 502).xfrm.unwrap().w, 900.0);
}
#[test]
fn full_bleed_covers_the_slide_and_puts_the_title_on_a_band() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
let px = shape(&s, 502).xfrm.unwrap();
assert_eq!((px.w, px.h), (960.0, 540.0));
let t = shape(&s, 500);
assert!(t.fill.is_some());
assert!(t.xfrm.unwrap().y > 300.0);
}
#[test]
fn every_idea_keeps_shapes_on_the_slide_and_unknown_ideas_are_refused() {
for pic in [true, false] {
let probe = deck(pic);
for (id, _) in ideas_for(&probe.doc().unwrap().doc, 0) {
let mut s = deck(pic);
s.execute("design.applyIdea", &json!({"id": id})).unwrap();
for sh in &s.doc().unwrap().doc.slides[0].shapes {
let x = sh.xfrm.unwrap();
assert!(x.x >= -0.01 && x.y >= -0.01 && x.x + x.w <= 960.01 && x.y + x.h <= 540.01 && x.w > 0.0 && x.h > 0.0, "{id}: {x:?}");
}
}
}
let mut s = deck(false);
assert!(s.execute("design.applyIdea", &json!({"id": "fullBleed"})).is_err());
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
}
#[test]
fn switching_from_full_bleed_removes_the_title_band() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only");
}
#[test]
fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() {
let mut s = deck(true);
let st = s.doc_mut().unwrap();
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
let hidden = Shape {
id: ShapeId(503),
hidden: true,
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
text: Some(deckcraft_model::text::TextBody::from_text("Old title")),
..Default::default()
};
slide.shapes.insert(0, hidden);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band");
assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched");
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band");
assert!(shape(&s, 503).fill.is_none());
}
#[test]
fn switching_ideas_removes_the_band_after_a_pptx_round_trip() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
s.doc_mut().unwrap().doc = Arc::new(back);
let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap();
let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0;
assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save");
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening");
}
#[test]
fn switching_ideas_removes_a_band_saved_by_earlier_versions() {
let mut s = deck(true);
let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)));
let st = s.doc_mut().unwrap();
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy);
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none());
}
#[test]
fn a_title_fill_set_by_the_user_survives_other_ideas() {
// Set through the command, and set to exactly the band's own fill.
let mut s = deck(true);
s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap();
let user = shape(&s, 500).fill;
assert!(user.is_some());
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert_eq!(shape(&s, 500).fill, user);
}
#[test]
fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap();
assert!(shape(&s, 500).fill.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
}
#[test]
fn cover_crop_matches_the_box_aspect() {
let c = cover_crop(2.0, 1.0);
assert!((c[0] - 0.25).abs() < 1e-12 && c[1] == 0.0);
let c = cover_crop(1.0, 2.0);
assert!((c[1] - 0.25).abs() < 1e-12 && c[0] == 0.0);
assert_eq!(cover_crop(f64::NAN, 1.0), [0.0; 4]);
}
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
]
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
"notes": sl.notes_text(),
"notesBody": sl.notes,
"comments": sl.comments.len(),
"hidden": sl.hidden,
}))
@@ -136,7 +137,8 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id").or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?;
let id =
id_param(p, "id", "shape.inspect")?.or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?;
let sh = st.shape(id).ok_or_else(|| bad("shape.inspect", format!("no shape {id}")))?;
let x = xfrm_of(&st.doc, &st.selection, sh);
Ok(json!({"shape": serde_json::to_value(sh).unwrap_or_default(), "box": x}))
+16 -1
View File
@@ -139,7 +139,7 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
let data = s.doc()?.doc.media(m.media).map(|i| i.data.clone()).ok_or_else(|| EngineError::Other("the media data is missing".into()))?;
let png = poster_png(&data, ms).ok_or_else(|| EngineError::Other("no frame could be decoded there".into()))?;
let name = format!("poster{id}.png");
let poster = s.edit(|doc, _| Ok(doc.add_media(&name, "image/png", png)))?;
let poster = s.edit(|doc, _| doc.add_media(&name, "image/png", png).ok_or_else(|| bad("media.posterFrame", NO_ID_SPACE)))?;
edit_shapes(s, &json!({"id": id}), "media.posterFrame", |sh| {
if let ShapeKind::Media(m) = &mut sh.kind {
m.poster = Some(poster);
@@ -228,6 +228,21 @@ mod tests {
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
}
#[test]
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
let mut s = Session::with_new();
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
let before = s.execute("media.info", &json!({})).expect("info");
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
let after = s.execute("media.info", &json!({})).expect("info");
for key in ["durationMs", "width", "height"] {
assert_eq!(after[key], before[key], "{key}");
}
}
#[test]
fn media_commands_need_media() {
let mut s = Session::with_new();
+144 -27
View File
@@ -30,39 +30,87 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// The filled outline of `s` in slide points (curves flattened).
/// The filled outline of `s` in slide points (curves flattened). A text box with no fill and no
/// outline is its letters, as in PowerPoint (merging text with a picture fills the letters).
fn area_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area {
if let Some((letters, _)) = letters_of(doc, sel, s) {
return letters;
}
let x = xfrm_of(doc, sel, s);
let geo = deckcraft_render::shape_geometry(s, x.w, x.h);
let a = x.affine();
let mut out: Area = vec![];
for sp in geo.paths.iter().filter(|p| p.fill != deckcraft_geom::preset::FillMode::None) {
let mut cur: Vec<[f64; 2]> = vec![];
let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| {
if cur.len() > 2 {
out.push(std::mem::take(cur));
} else {
cur.clear();
}
};
deckcraft_geom::preset_flatten(&sp.path, &mut |el| match el {
PathEl::MoveTo(p) => {
flush(&mut cur, &mut out);
let q = a * p;
cur.push([q.x, q.y]);
}
PathEl::LineTo(p) => {
let q = a * p;
cur.push([q.x, q.y]);
}
PathEl::ClosePath => flush(&mut cur, &mut out),
_ => {}
});
flush(&mut cur, &mut out);
flatten(&sp.path, a, &mut out);
}
out
}
/// The letters of a text box with no fill and no outline, in slide points, and their colour.
fn letters_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Option<(Area, Option<deckcraft_color::Rgba>)> {
use deckcraft_model::resolve;
use deckcraft_model::style::Fill;
if !matches!(s.kind, ShapeKind::Shape) || s.text.as_ref().is_none_or(|t| t.is_empty()) {
return None;
}
let ctx = super::text::ctx_for(doc, sel)?;
let none = |f: Option<&Fill>| f.is_none_or(Fill::is_none);
if !none(resolve::fill(&ctx, s).0.as_ref()) || !none(resolve::line(&ctx, s).0.fill.as_ref()) {
return None;
}
let (path, colour) = deckcraft_render::text_outline(&ctx, s, xfrm_of(doc, sel, s).affine());
let mut out: Area = vec![];
flatten(&path, deckcraft_geom::Affine::IDENTITY, &mut out);
(!out.is_empty()).then_some((out, colour))
}
/// Append the closed polygons of `path` (mapped by `a`) to `out`.
fn flatten(path: &deckcraft_geom::BezPath, a: deckcraft_geom::Affine, out: &mut Area) {
let mut cur: Vec<[f64; 2]> = vec![];
let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| {
if cur.len() > 2 {
out.push(std::mem::take(cur));
} else {
cur.clear();
}
};
deckcraft_geom::preset_flatten(path, &mut |el| match el {
PathEl::MoveTo(p) => {
flush(&mut cur, out);
let q = a * p;
cur.push([q.x, q.y]);
}
PathEl::LineTo(p) => {
let q = a * p;
cur.push([q.x, q.y]);
}
PathEl::ClosePath => flush(&mut cur, out),
_ => {}
});
flush(&mut cur, out);
}
/// Keep a picture where it was on the slide when its box becomes `to`: crop it to the new box.
/// Only for upright, unflipped pictures stretched over their box.
fn recrop(fill: &mut deckcraft_model::style::PictureFill, from: &Xfrm, to: &Xfrm) {
use deckcraft_model::style::PictureMode;
if from.rot != 0.0 || from.flip_h || from.flip_v || !matches!(fill.mode, PictureMode::Stretch { fill_rect } if fill_rect == [0.0; 4]) {
return;
}
let [l, t, r, b] = fill.crop;
let (fw, fh) = (1.0 - l - r, 1.0 - t - b);
if !(fw > 1e-9 && fh > 1e-9 && from.w > 0.0 && from.h > 0.0) {
return;
}
// The whole image in slide points.
let (iw, ih) = (from.w / fw, from.h / fh);
let (ix, iy) = (from.x - l * iw, from.y - t * ih);
let c = [(to.x - ix) / iw, (to.y - iy) / ih, (ix + iw - to.x - to.w) / iw, (iy + ih - to.y - to.h) / ih];
if c.iter().all(|v| v.is_finite()) {
fill.crop = c;
}
}
fn overlay(a: &Area, b: &Area, rule: OverlayRule) -> Area {
if a.is_empty() && matches!(rule, OverlayRule::Union | OverlayRule::Xor) {
return b.clone();
@@ -155,7 +203,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let areas: Vec<Area> = shapes.iter().map(|x| area_of(&st.doc, &st.selection, x)).collect();
let results = merge_areas(&op, &areas);
let template = shapes[0].clone();
let mut template = shapes[0].clone();
let template_box = xfrm_of(&st.doc, &st.selection, &template);
// Letters merged become shapes filled with the text's colour.
let letters = letters_of(&st.doc, &st.selection, &template);
if let Some((_, colour)) = &letters {
use deckcraft_model::style::{ColorRef, Fill, Line};
template.fill = Some(Fill::solid(ColorRef::rgb(colour.unwrap_or(deckcraft_color::Rgba::BLACK))));
template.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
template.text_box = false;
}
let drop_text = letters.is_some() || shapes.iter().skip(1).any(|x| letters_of(&st.doc, &st.selection, x).is_some());
let mut new_ids = vec![];
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?;
@@ -169,14 +227,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
sh.xfrm = Some(x);
sh.geom = geom;
sh.name = format!("Freeform {}", k + 1);
// Text stays with the first piece only.
if k > 0 {
if let ShapeKind::Picture { fill } = &mut sh.kind {
recrop(fill, &template_box, &x);
}
// Text stays with the first piece only, and not at all once it became outlines.
if k > 0 || drop_text {
sh.text = None;
}
made.push(sh);
}
for sh in &mut made {
sh.id = ShapeId(doc.alloc_id());
sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad("shape.merge", NO_ID_SPACE))?);
new_ids.push(sh.id);
}
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?;
@@ -216,4 +277,60 @@ mod tests {
// Disjoint intersect is empty.
assert!(merge_areas("intersect", &[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)]).is_empty());
}
fn picture_and_text(s: &mut Session) -> (ShapeId, ShapeId) {
let mut png = Vec::new();
image::RgbaImage::from_pixel(40, 20, image::Rgba([0, 128, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("png");
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
let r = s
.execute("insert.picture", &json!({"name": "a.png", "data": base64_encode(&png), "rect": [100.0, 100.0, 400.0, 200.0]}))
.expect("picture");
let pic = ShapeId(r["id"].as_u64().expect("id") as u32);
let r = s.execute("insert.textBox", &json!({"rect": [150.0, 120.0, 300.0, 100.0], "text": "HI"})).expect("text box");
let text = ShapeId(r["id"].as_u64().expect("id") as u32);
(pic, text)
}
#[test]
fn a_picture_intersected_with_text_fills_the_letters() {
// #653: the letters didn't change and the picture vanished (the text box counted as its
// whole box, and the merge took the text's outline but kept the box's formatting).
let mut s = Session::with_new();
let (pic, text) = picture_and_text(&mut s);
let st = s.doc().expect("doc");
let letters = letters_of(&st.doc, &st.selection, st.shape(text).expect("text")).expect("the text box is its letters").0;
let box_area = 300.0 * 100.0;
assert!(area_size(&letters) > 10.0 && area_size(&letters) < box_area * 0.5, "{}", area_size(&letters));
let r = s.execute("shape.merge", &json!({"op": "intersect", "ids": [pic.0, text.0]})).expect("merge");
let ids = r["ids"].as_array().expect("ids");
assert_eq!(ids.len(), 1);
let st = s.doc().expect("doc");
let out = st.shape(ShapeId(ids[0].as_u64().expect("id") as u32)).expect("result").clone();
let ShapeKind::Picture { fill } = &out.kind else { panic!("the picture fills the letters: {:?}", out.kind) };
assert!(out.text.is_none(), "the letters are outlines now");
// The result is the letters' outline, well inside the text box.
let x = out.xfrm.expect("box");
assert!(x.x >= 150.0 && x.y >= 120.0 && x.x + x.w <= 450.0 && x.y + x.h <= 220.0, "{x:?}");
let merged = area_of(&st.doc, &st.selection, &out);
assert!((area_size(&merged) - area_size(&letters)).abs() < area_size(&letters) * 0.05, "{} vs {}", area_size(&merged), area_size(&letters));
// The picture stays where it was on the slide: cropped to the new box.
let [l, t, rr, b] = fill.crop;
let iw = x.w / (1.0 - l - rr);
let ih = x.h / (1.0 - t - b);
assert!((x.x - l * iw - 100.0).abs() < 1e-6 && (iw - 400.0).abs() < 1e-6, "{:?}", fill.crop);
assert!((x.y - t * ih - 100.0).abs() < 1e-6 && (ih - 200.0).abs() < 1e-6, "{:?}", fill.crop);
}
#[test]
fn text_merged_first_becomes_shapes_in_its_colour() {
let mut s = Session::with_new();
let (pic, text) = picture_and_text(&mut s);
let r = s.execute("shape.merge", &json!({"op": "union", "ids": [text.0, pic.0]})).expect("merge");
let st = s.doc().expect("doc");
let out = st.shape(ShapeId(r["ids"][0].as_u64().expect("id") as u32)).expect("result");
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
}
}
+277 -10
View File
@@ -2,21 +2,34 @@
pub mod animation;
pub mod arrange;
pub mod artistic;
pub mod background;
pub mod compress;
pub mod design;
pub mod edit;
pub mod file;
pub mod find;
pub mod format;
pub mod ideas;
pub mod insert;
pub mod inspect;
pub mod media;
pub mod merge;
pub mod picture_style;
pub mod points;
pub mod recolor;
pub mod reuse;
pub mod review;
mod richclip;
pub mod selection;
pub mod shape;
pub mod slide;
pub mod smartart;
pub mod table;
pub mod text;
pub mod three_d;
pub mod transition;
pub mod zoom;
use deckcraft_geom::Xfrm;
use deckcraft_model::ShapeId;
@@ -34,6 +47,9 @@ pub struct CommandSpec {
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
pub menu: &'static [&'static str],
pub shortcut: Option<&'static str>,
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
@@ -54,6 +70,28 @@ pub struct CommandInfo {
}
impl CommandSpec {
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
pub fn acts_on(&self) -> Option<String> {
let keys = param_keys(self.params).unwrap_or_default();
let named = if keys.contains(&"ids") {
Some("the shapes in `ids`")
} else if keys.contains(&"id") {
Some("the shape in `id`")
} else {
None
};
let reason = (self.enabled)(&Session::with_new()).err()?;
Some(match (reason.as_str(), named) {
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
_ => return None,
})
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
@@ -80,6 +118,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
}
pub const NOTHING_SELECTED: &str = "nothing selected";
pub const SELECT_TWO: &str = "select two or more objects";
pub const NO_TEXT_POINT: &str = "no text insertion point";
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
@@ -90,17 +130,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
}
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
}
/// Text editing or shapes with text selected (formatting commands apply to either).
/// Text editing or selected shapes (formatting commands apply to either).
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
Ok(())
} else {
Err("select text or a shape".into())
}
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
}
@@ -128,20 +165,31 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(file::specs());
v.extend(edit::specs());
v.extend(slide::specs());
v.extend(reuse::specs());
v.extend(insert::specs());
v.extend(smartart::specs());
v.extend(shape::specs());
v.extend(background::specs());
v.extend(picture_style::specs());
v.extend(recolor::specs());
v.extend(arrange::specs());
v.extend(selection::specs());
v.extend(merge::specs());
v.extend(points::specs());
v.extend(text::specs());
v.extend(format::specs());
v.extend(design::specs());
v.extend(ideas::specs());
v.extend(transition::specs());
v.extend(animation::specs());
v.extend(table::specs());
v.extend(find::specs());
v.extend(review::specs());
v.extend(inspect::specs());
v.extend(media::specs());
v.extend(artistic::specs());
v.extend(zoom::specs());
v.extend(compress::specs());
v
})
}
@@ -150,8 +198,105 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
let mut keys = vec![];
for p in parse_params(doc)? {
if !keys.contains(&p.name) {
keys.push(p.name);
}
}
Ok(keys)
}
#[derive(Debug, PartialEq)]
pub struct Param<'a> {
pub name: &'a str,
pub optional: bool,
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
pub about: &'a str,
/// An alternative to the parameter before it in the same object (`color? | none?`).
pub or: bool,
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
pub object: usize,
}
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
/// and is skipped. Errs where the doc strays from that grammar.
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
let doc = doc.split('→').next().unwrap_or_default();
let mut params: Vec<Param> = vec![];
let mut open: Vec<char> = vec![];
let mut expect_key = false;
let mut or = false;
let mut objects: usize = 0;
let mut about_from: Option<usize> = None;
let mut chars = doc.char_indices().peekable();
let mut prev = ' ';
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
p.about = doc.get(f..to).unwrap_or_default().trim();
}
}
while let Some((i, c)) = chars.next() {
let in_object = open == ['{'];
match c {
'{' | '[' | '(' => {
open.push(c);
if open == ['{'] {
expect_key = true;
objects += 1;
or = false;
}
}
'}' | ']' | ')' => {
if in_object {
end_about(doc, &mut params, &mut about_from, i);
}
open.pop();
}
',' if in_object => {
end_about(doc, &mut params, &mut about_from, i);
expect_key = true;
}
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
end_about(doc, &mut params, &mut about_from, i);
expect_key = true;
or = true;
}
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
let mut end = i + c.len_utf8();
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
end = j + n.len_utf8();
chars.next();
}
let after = doc.get(end..).unwrap_or_default();
let rest = after.trim_start_matches('?').trim_start();
let at = doc.len() - rest.len();
about_from = match rest.chars().next() {
Some(':') => Some(at + 1),
Some('(') => Some(at),
Some(',' | '}' | '|') => None,
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
};
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
or = false;
expect_key = false;
}
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
_ => {}
}
prev = c;
}
Ok(params)
}
// ---------- param helpers ----------
/// The deck has used every object id (a hostile file can arrive that way): it cannot grow, and
/// growing would duplicate ids (#407).
pub(crate) const NO_ID_SPACE: &str = "the presentation has used every object id and can hold no more";
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
@@ -173,8 +318,25 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
}
pub(crate) fn id_param(p: &Value, key: &str) -> Option<ShapeId> {
p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).map(ShapeId)
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
}
}
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.map(|v| Some(ShapeId(v)))
.ok_or_else(|| bad(cmd, format!("`{key}` is not a valid shape id: {v}"))),
}
}
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<ShapeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(ShapeId).collect())
@@ -198,7 +360,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
{
return Ok(ids);
}
if let Some(id) = id_param(p, "id") {
if let Some(id) = id_param(p, "id", "shape")? {
return Ok(vec![id]);
}
Ok(s.doc()?.selection.shapes.clone())
@@ -274,6 +436,9 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
match sel.target {
crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)),
crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)),
t @ (crate::Target::Handout | crate::Target::NotesMaster) => {
crate::page_master(doc, t).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape))
}
crate::Target::Layout { master, layout } => {
doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape)))
}
@@ -327,10 +492,83 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
match sel.target {
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes),
t @ (crate::Target::Handout | crate::Target::NotesMaster) => crate::page_master(doc, t).map(|m| &m.shapes),
crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes),
}
}
/// The command palette runs a command with `{}`. True when the params doc names a value that
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
pub fn needs_arguments(params: &str) -> bool {
let input = params.split('→').next().unwrap_or(params);
let Some(start) = input.find('{') else { return false };
let inside = input.get(start + 1..).unwrap_or("");
let inside = inside.split('}').next().unwrap_or(inside);
split_fields(inside).iter().any(|f| field_requires(f.trim()))
}
fn split_fields(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut depth = 0i32;
let mut quote = false;
for (i, c) in s.char_indices() {
if quote {
if c == '"' {
quote = false;
}
continue;
}
match c {
'"' => quote = true,
'[' | '(' => depth += 1,
']' | ')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
out.push(s.get(start..i).unwrap_or(""));
start = i + c.len_utf8();
}
_ => {}
}
}
out.push(s.get(start..).unwrap_or(""));
out
}
/// A field needs a value unless every alternative (`path? | data`) is an optional parameter.
fn field_requires(field: &str) -> bool {
if field.is_empty() {
return false;
}
let alts: Vec<&str> = if field.contains(" | ") { field.split(" | ").map(str::trim).filter(|s| !s.is_empty()).collect() } else { vec![field] };
let mut saw = false;
let mut all_optional = true;
for alt in alts {
let Some(optional) = param_optional(alt) else { continue };
saw = true;
all_optional &= optional;
}
saw && !all_optional
}
/// `Some(true)` when `alt` starts with an optional parameter name, `Some(false)` when required.
fn param_optional(alt: &str) -> Option<bool> {
let alt = alt.trim();
let name_end = alt.find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(alt.len());
let name = alt.get(..name_end).unwrap_or("");
if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
let rest = alt.get(name_end..).unwrap_or("").trim_start();
if rest.starts_with('?') {
Some(true)
} else if rest.is_empty() || rest.starts_with(':') {
Some(false)
} else {
None
}
}
/// Base64 (standard alphabet) for binary payloads in JSON params and results.
pub fn base64_encode(data: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -373,3 +611,32 @@ pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_palette_skips_a_command_that_needs_a_value() {
assert!(needs_arguments("{path}"));
assert!(needs_arguments("{path, format: png|jpeg|pptx, slide?: index}"));
assert!(needs_arguments("{path? | data: base64, name?, rect?}"));
assert!(needs_arguments("{rows, cols, rect?}"));
assert!(needs_arguments("{axis: horizontal|vertical, ids?}"));
assert!(needs_arguments("{percent}"));
assert!(!needs_arguments("{}"));
assert!(!needs_arguments("{on?: bool}"));
assert!(!needs_arguments("{theme?: name, size?: [w,h] pt, blank?: bool}"));
assert!(!needs_arguments("{uid?: one entry, else all}"));
assert!(!needs_arguments("{locked?: bool (default: toggle), ids?}"));
assert!(!needs_arguments("{} → [{name, label, category}]"));
assert!(!needs_arguments("{title?, subject?} → properties"));
assert!(!needs_arguments("{slides?: [index] | \"1-3,5\", includeHidden?}"));
let export = command_specs().iter().find(|c| c.id == "file.export").expect("export");
let save = command_specs().iter().find(|c| c.id == "file.save").expect("save");
let audio = command_specs().iter().find(|c| c.id == "insert.audio").expect("audio");
assert!(needs_arguments(export.params));
assert!(!needs_arguments(save.params));
assert!(needs_arguments(audio.params));
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Picture Format ▸ Picture Styles: PowerPoint's quick styles for pictures, each a frame (shape,
//! outline) and effects applied as one step, replacing the previous style.
use deckcraft_color::{ColorTransform, Rgba};
use deckcraft_model::style::{Effects, Reflection, Shadow};
use deckcraft_model::{ColorRef, Geom, Line, Shape};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"picture.style",
"Picture Styles",
["Picture Format", "Picture Styles"],
None,
"{style: simpleFrameWhite|dropShadowRectangle|softEdgeOval|… | none (see picture.styles), ids?}",
has_selection,
style
),
cmd!(query "picture.styles", "Picture Style Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
STYLES.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
))),
]
}
/// (id, label), in PowerPoint's gallery order.
pub const STYLES: [(&str, &str); 24] = [
("simpleFrameWhite", "Simple Frame, White"),
("beveledMatteWhite", "Beveled Matte, White"),
("metalFrame", "Metal Frame"),
("dropShadowRectangle", "Drop Shadow Rectangle"),
("roundedDiagonalCornerWhite", "Rounded Diagonal Corner, White"),
("snipDiagonalCornerWhite", "Snip Diagonal Corner, White"),
("moderateFrameWhite", "Moderate Frame, White"),
("rotatedWhite", "Rotated, White"),
("perspectiveShadowWhite", "Perspective Shadow, White"),
("softEdgeRectangle", "Soft Edge Rectangle"),
("doubleFrameBlack", "Double Frame, Black"),
("thickMatteBlack", "Thick Matte, Black"),
("simpleFrameBlack", "Simple Frame, Black"),
("beveledOvalBlack", "Beveled Oval, Black"),
("compoundFrameBlack", "Compound Frame, Black"),
("centerShadowRectangle", "Center Shadow Rectangle"),
("softEdgeOval", "Soft Edge Oval"),
("reflectedRoundedRectangle", "Reflected Rounded Rectangle"),
("reflectedBevelWhite", "Reflected Bevel, White"),
("metalRoundedRectangle", "Metal Rounded Rectangle"),
("moderateFrameBlack", "Moderate Frame, Black"),
("bevelRectangle", "Bevel Rectangle"),
("bevelPerspective", "Bevel Perspective"),
("metalOval", "Metal Oval"),
];
/// The look of a style: geometry, outline (colour, width) and effects.
pub struct Look {
pub preset: &'static str,
pub frame: Option<(Rgba, f64)>,
pub shadow: Option<&'static str>,
pub soft_edge: Option<f64>,
pub reflection: bool,
pub bevel: Option<&'static str>,
pub rot: Option<f64>,
}
pub fn look(id: &str) -> Option<Look> {
let white = Rgba::rgb(0xFF, 0xFF, 0xFF);
let black = Rgba::rgb(0, 0, 0);
let silver = Rgba::rgb(0xC0, 0xC0, 0xC0);
let l = |preset, frame, shadow| Look { preset, frame, shadow, soft_edge: None, reflection: false, bevel: None, rot: None };
Some(match id {
"simpleFrameWhite" => l("rect", Some((white, 7.0)), Some("soft")),
"beveledMatteWhite" => Look { bevel: Some("circle"), ..l("rect", Some((white, 6.0)), Some("soft")) },
"metalFrame" => Look { bevel: Some("angle"), ..l("rect", Some((silver, 7.0)), Some("soft")) },
"dropShadowRectangle" => l("rect", None, Some("drop")),
"roundedDiagonalCornerWhite" => l("round2DiagRect", Some((white, 6.0)), Some("soft")),
"snipDiagonalCornerWhite" => l("snip2DiagRect", Some((white, 6.0)), Some("soft")),
"moderateFrameWhite" => l("rect", Some((white, 4.5)), Some("soft")),
"rotatedWhite" => Look { rot: Some(356.0), ..l("rect", Some((white, 6.0)), Some("soft")) },
"perspectiveShadowWhite" => l("rect", Some((white, 4.0)), Some("perspective")),
"softEdgeRectangle" => Look { soft_edge: Some(10.0), ..l("rect", None, None) },
"doubleFrameBlack" => l("rect", Some((black, 6.0)), None),
"thickMatteBlack" => l("rect", Some((black, 10.0)), Some("soft")),
"simpleFrameBlack" => l("rect", Some((black, 5.0)), None),
"beveledOvalBlack" => Look { bevel: Some("circle"), ..l("ellipse", Some((black, 4.0)), Some("soft")) },
"compoundFrameBlack" => l("rect", Some((black, 4.5)), None),
"centerShadowRectangle" => l("rect", None, Some("center")),
"softEdgeOval" => Look { soft_edge: Some(12.0), ..l("ellipse", None, None) },
"reflectedRoundedRectangle" => Look { reflection: true, ..l("roundRect", None, None) },
"reflectedBevelWhite" => Look { reflection: true, bevel: Some("circle"), ..l("rect", Some((white, 4.0)), None) },
"metalRoundedRectangle" => Look { bevel: Some("angle"), ..l("roundRect", Some((silver, 6.0)), Some("soft")) },
"moderateFrameBlack" => l("rect", Some((black, 4.5)), Some("soft")),
"bevelRectangle" => Look { bevel: Some("angle"), ..l("rect", None, Some("soft")) },
"bevelPerspective" => Look { bevel: Some("relaxedInset"), ..l("rect", None, Some("perspective")) },
"metalOval" => Look { bevel: Some("angle"), ..l("ellipse", Some((silver, 6.0)), Some("soft")) },
_ => return None,
})
}
fn shadow(kind: &str) -> Shadow {
let base = Shadow {
color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)),
blur: 4.0,
dist: 3.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: "tl".into(),
rotate_with_shape: false,
};
match kind {
"drop" => Shadow { blur: 12.0, dist: 6.0, dir: 90.0, ..base },
"center" => Shadow { blur: 16.0, dist: 0.0, ..base },
"perspective" => Shadow { sy: 0.23, kx: -20.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base },
_ => base,
}
}
/// Apply style `id` (or clear the style with `none`) to `sh`.
pub fn apply(sh: &mut Shape, id: &str) -> bool {
if id == "none" {
sh.geom = Geom::preset("rect");
sh.line = None;
sh.effects = None;
return true;
}
let Some(lk) = look(id) else { return false };
sh.geom = Geom::preset(lk.preset);
sh.line = Some(lk.frame.map_or_else(Line::none, |(c, w)| Line::solid(ColorRef::rgb(c), w)));
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 }),
bevel: lk.bevel.map(str::to_string),
..Default::default()
};
sh.effects = (!e.is_empty()).then_some(e);
if let (Some(r), Some(x)) = (lk.rot, sh.xfrm.as_mut()) {
x.rot = r;
}
true
}
fn style(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "style").ok_or_else(|| bad("picture.style", "missing `style`"))?.to_string();
if id != "none" && look(&id).is_none() {
return Err(bad("picture.style", format!("unknown style `{id}`; see picture.styles")));
}
edit_shapes(s, p, "picture.style", |sh| {
apply(sh, &id);
Ok(())
})
}
+786
View File
@@ -0,0 +1,786 @@
//! Edit Points (Shape Format ▸ Edit Shape ▸ Edit Points): turn a shape into a freeform and move,
//! add or delete the vertices of its outline.
//!
//! While points are edited every custom path of the shape is kept in shape-local points (its
//! `w`/`h` equal the shape's size), so a vertex's path coordinates are its offset in the shape.
//! After each change the shape's box is fitted around the outline again, keeping it in place on
//! the slide (rotation and flips included).
use deckcraft_geom::{PathEl, Point, Xfrm};
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.editPoints",
"Edit Points",
["Shape Format", "Insert Shapes", "Edit Shape"],
None,
"{on?: bool (default: toggle), id?} — enter or leave Edit Points for one selected shape; entering turns a preset into a freeform",
has_selection,
edit_points
),
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points),
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
cmd!(
"shape.addPoint",
"Add Point",
[],
None,
"{path?: 0, segment: index of the vertex ending it, t?: 0..1 (0.5), id?}",
has_selection,
add_point
),
cmd!(
"shape.pointType",
"Point Type",
[],
None,
"{path?: 0, index, type: smooth|straight|corner, id?} — smooth: handles in line and equal; straight: in line; corner: independent",
has_selection,
point_type
),
cmd!(
"shape.moveHandle",
"Move Handle",
[],
None,
"{path?: 0, index, side: in|out, x, y: slide pt, type?: smooth|straight|corner (default: read from the handles), id?}",
has_selection,
move_handle
),
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
]
}
/// One path command, in path units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Seg {
M(Point),
L(Point),
C(Point, Point, Point),
Q(Point, Point),
Z,
}
impl Seg {
pub fn end(&self) -> Option<Point> {
match self {
Seg::M(p) | Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p) => Some(*p),
Seg::Z => None,
}
}
fn points_mut(&mut self) -> Vec<&mut Point> {
match self {
Seg::M(p) | Seg::L(p) => vec![p],
Seg::C(a, b, p) => vec![a, b, p],
Seg::Q(a, p) => vec![a, p],
Seg::Z => vec![],
}
}
}
/// Parses path data (`M x y L x y C … Q … Z`); malformed commands are skipped.
pub fn parse(d: &str) -> Vec<Seg> {
let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect();
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
let pt = |i: usize| Some(Point::new(num(i)?, num(i + 1)?));
let mut out = Vec::new();
let mut i = 0;
while i < toks.len() && out.len() < 100_000 {
let (seg, n) = match toks.get(i).copied().unwrap_or("") {
"M" => (pt(i + 1).map(Seg::M), 3),
"L" => (pt(i + 1).map(Seg::L), 3),
"C" => ((|| Some(Seg::C(pt(i + 1)?, pt(i + 3)?, pt(i + 5)?)))(), 7),
"Q" => ((|| Some(Seg::Q(pt(i + 1)?, pt(i + 3)?)))(), 5),
"Z" => (Some(Seg::Z), 1),
_ => (None, 1),
};
// Make a closing edge explicit, so it is a segment that can take points.
if matches!(seg, Some(Seg::Z)) {
let start = out.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None });
let end = out.last().and_then(Seg::end);
if let (Some(a), Some(b)) = (start, end)
&& (a - b).hypot() > 1e-6
{
out.push(Seg::L(a));
}
}
if let Some(s) = seg {
// A path starts with a move.
let s = match (out.is_empty(), s) {
(true, Seg::L(p)) => Seg::M(p),
(true, Seg::C(..) | Seg::Q(..) | Seg::Z) => {
i += n;
continue;
}
(_, s) => s,
};
out.push(s);
}
i += n;
}
out
}
/// Path data for `segs`.
pub fn write(segs: &[Seg]) -> String {
let f = |v: f64| {
let s = format!("{:.3}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
};
let p = |q: &Point| format!("{} {}", f(q.x), f(q.y));
segs.iter()
.map(|s| match s {
Seg::M(a) => format!("M {}", p(a)),
Seg::L(a) => format!("L {}", p(a)),
Seg::C(a, b, c) => format!("C {} {} {}", p(a), p(b), p(c)),
Seg::Q(a, b) => format!("Q {} {}", p(a), p(b)),
Seg::Z => "Z".into(),
})
.collect::<Vec<_>>()
.join(" ")
}
/// Whether segment `i` ends back on its sub-path's start right before a `Z` (the closing edge of
/// a closed outline). Its end is the same vertex as the start, not a vertex of its own.
fn closes_onto_start(segs: &[Seg], i: usize) -> bool {
if !matches!(segs.get(i + 1), Some(Seg::Z)) {
return false;
}
let start = segs.get(..=i).and_then(|s| s.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }));
match (start, segs.get(i).and_then(Seg::end)) {
(Some(a), Some(b)) => !matches!(segs.get(i), Some(Seg::M(_))) && (a - b).hypot() < 1e-6,
_ => false,
}
}
/// The segments whose end is a vertex, in order.
pub fn vertices(segs: &[Seg]) -> Vec<usize> {
(0..segs.len()).filter(|i| segs.get(*i).and_then(Seg::end).is_some() && !closes_onto_start(segs, *i)).collect()
}
fn shift(segs: &mut [Seg], i: usize, which: &[usize], d: deckcraft_geom::Vec2) {
if let Some(s) = segs.get_mut(i) {
let mut pts = s.points_mut();
for w in which {
if let Some(p) = pts.get_mut(*w) {
**p += d;
}
}
}
}
/// The indices of the points of segment `i` that sit on its end vertex (the end and, for a
/// cubic, its incoming control point).
fn end_points(seg: Option<&Seg>) -> &'static [usize] {
match seg {
Some(Seg::C(..)) => &[1, 2],
Some(Seg::Q(..)) => &[1],
Some(Seg::M(_) | Seg::L(_)) => &[0],
_ => &[],
}
}
/// Moves the vertex ending segment `si` by `d`, with the control points attached to it.
fn move_vertex(segs: &mut [Seg], si: usize, d: deckcraft_geom::Vec2) {
// A closed sub-path's start is also its closing edge's end.
let close = if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
} else {
None
};
let own = end_points(segs.get(si));
shift(segs, si, own, d);
// The outgoing curve's first control point follows the vertex.
if matches!(segs.get(si + 1), Some(Seg::C(..))) {
shift(segs, si + 1, &[0], d);
}
if let Some(j) = close {
let w = end_points(segs.get(j));
shift(segs, j, w, d);
}
}
/// The point where segment `si` starts.
fn seg_start(segs: &[Seg], si: usize) -> Option<Point> {
let prev = segs.get(..si)?;
match prev.last()? {
Seg::Z => prev.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }),
s => s.end(),
}
}
/// Splits segment `si` at `t`, adding a vertex there. Returns false for a move or close.
fn split(segs: &mut Vec<Seg>, si: usize, t: f64) -> bool {
let Some(p0) = seg_start(segs, si) else { return false };
let lerp = |a: Point, b: Point| a.lerp(b, t);
let (a, b) = match segs.get(si).copied() {
Some(Seg::L(p1)) => (Seg::L(lerp(p0, p1)), Seg::L(p1)),
Some(Seg::Q(c, p1)) => {
let (m0, m1) = (lerp(p0, c), lerp(c, p1));
let m = m0.lerp(m1, t);
(Seg::Q(m0, m), Seg::Q(m1, p1))
}
Some(Seg::C(c1, c2, p1)) => {
let (a1, a2, a3) = (lerp(p0, c1), lerp(c1, c2), lerp(c2, p1));
let (b1, b2) = (a1.lerp(a2, t), a2.lerp(a3, t));
let m = b1.lerp(b2, t);
(Seg::C(a1, b1, m), Seg::C(b2, a3, p1))
}
_ => return false,
};
if let Some(s) = segs.get_mut(si) {
*s = a;
}
segs.insert(si + 1, b);
true
}
/// Removes the vertex ending segment `si`. A sub-path keeps at least two vertices.
fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
// The vertices of `si`'s sub-path.
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
let count = vertices(segs).iter().filter(|v| (from..to).contains(*v)).count();
if count <= 2 {
return false;
}
if matches!(segs.get(si), Some(Seg::M(_))) {
// The next vertex becomes the start; a closed outline's closing edge now ends there.
let Some(next) = segs.get(si + 1).and_then(Seg::end) else { return false };
segs.remove(si + 1);
if let Some(s) = segs.get_mut(si) {
*s = Seg::M(next);
}
let close = (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| matches!(segs.get(j + 1), Some(Seg::Z)));
if let Some(j) = close
&& let Some(Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p)) = segs.get_mut(j)
{
*p = next;
}
return true;
}
// Merge with the next segment: it now starts where the removed vertex's segment started.
match (segs.get(si).copied(), segs.get(si + 1).copied()) {
(Some(Seg::C(c1, _, _)), Some(Seg::C(_, c2, p))) => {
segs.remove(si + 1);
if let Some(s) = segs.get_mut(si) {
*s = Seg::C(c1, c2, p);
}
}
_ => {
segs.remove(si);
}
}
true
}
/// Custom paths of a preset shape, in shape-local points.
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
let geo = deckcraft_render::shape_geometry(sh, w, h);
geo.paths
.iter()
.map(|sp| {
let segs: Vec<Seg> = sp
.path
.elements()
.iter()
.map(|e| match *e {
PathEl::MoveTo(p) => Seg::M(p),
PathEl::LineTo(p) => Seg::L(p),
PathEl::QuadTo(a, p) => Seg::Q(a, p),
PathEl::CurveTo(a, b, p) => Seg::C(a, b, p),
PathEl::ClosePath => Seg::Z,
})
.collect();
CustomPath { w, h, d: write(&segs), fill: sp.fill, stroke: sp.stroke }
})
.collect()
}
/// Rescales every path of a custom geometry to shape-local points.
fn normalize(paths: &mut [CustomPath], w: f64, h: f64) {
for p in paths {
let (sx, sy) = (if p.w > 0.0 { w / p.w } else { 1.0 }, if p.h > 0.0 { h / p.h } else { 1.0 });
if (sx - 1.0).abs() > 1e-12 || (sy - 1.0).abs() > 1e-12 {
let mut segs = parse(&p.d);
for s in &mut segs {
for q in s.points_mut() {
*q = Point::new(q.x * sx, q.y * sy);
}
}
p.d = write(&segs);
}
p.w = w;
p.h = h;
}
}
/// Fits the box around the outline, keeping the drawing where it is on the slide.
fn refit(sh: &mut Shape) {
let (Some(x), Geom::Custom { paths }) = (sh.xfrm, &mut sh.geom) else { return };
let mut all: Vec<Vec<Seg>> = paths.iter().map(|p| parse(&p.d)).collect();
let mut bb: Option<deckcraft_geom::Rect> = None;
for s in all.iter_mut().flatten() {
for q in s.points_mut() {
bb = Some(bb.map_or(deckcraft_geom::Rect::from_points(*q, *q), |b| b.union_pt(*q)));
}
}
let Some(bb) = bb else { return };
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
if bb.x0.abs() < 1e-6 && bb.y0.abs() < 1e-6 && (w - x.w).abs() < 1e-6 && (h - x.h).abs() < 1e-6 {
return;
}
let c = x.affine() * Point::new(bb.x0 + w / 2.0, bb.y0 + h / 2.0);
let off = deckcraft_geom::Vec2::new(bb.x0, bb.y0);
for (p, segs) in paths.iter_mut().zip(&mut all) {
for s in segs.iter_mut() {
for q in s.points_mut() {
*q -= off;
}
}
p.d = write(segs);
p.w = w;
p.h = h;
}
sh.xfrm = Some(Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..x });
}
/// The one shape whose points are edited: `id`, or the single selected shape.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
let st = s.doc()?;
let id = match id_param(p, "id", cmd)? {
Some(i) => i,
None => match st.selection.shapes.as_slice() {
[one] => *one,
_ => return Err(bad(cmd, "select one shape")),
},
};
let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?;
if !st.shapes().iter().any(|t| t.id == id) {
return Err(bad(cmd, "a shape in a group can't have its points edited; ungroup it first"));
}
if !matches!(sh.kind, ShapeKind::Shape) || sh.is_line() || sh.xfrm.is_none() {
return Err(bad(cmd, "only a shape with an outline has points to edit"));
}
if sh.locked {
return Err(bad(cmd, "the shape is locked"));
}
Ok(id)
}
/// Makes `id` a freeform in shape-local points (the state Edit Points works in).
pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
let st = s.doc()?;
let Some(sh) = st.shape(id) else { return Ok(()) };
let Some(x) = sh.xfrm else { return Ok(()) };
let ready = matches!(&sh.geom, Geom::Custom { paths } if paths.iter().all(|p| p.w == x.w && p.h == x.h));
if ready {
return Ok(());
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.editPoints", "no slide"))?;
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
match &mut sh.geom {
Geom::Custom { paths } => normalize(paths, x.w, x.h),
Geom::Preset { .. } => {
let paths = to_custom(sh, x.w, x.h);
sh.geom = Geom::Custom { paths };
}
}
}
Ok(())
})
}
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
s.tool.edit_vertex = None;
if !on {
s.tool.edit_points = None;
return Ok(json!({"editing": Value::Null}));
}
let id = target(s, p, "shape.editPoints")?;
prepare(s, id)?;
s.tool.edit_points = Some(id);
Ok(json!({"editing": id.0}))
}
/// Vertices of every path of `sh` in slide points.
pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape) -> Vec<Vec<Point>> {
let Geom::Custom { paths } = &sh.geom else { return vec![] };
let x = xfrm_of(doc, sel, sh);
let a = x.affine();
paths
.iter()
.map(|p| {
let (sx, sy) = (if p.w > 0.0 { x.w / p.w } else { 1.0 }, if p.h > 0.0 { x.h / p.h } else { 1.0 });
let segs = parse(&p.d);
vertices(&segs).iter().filter_map(|i| segs.get(*i).and_then(Seg::end)).map(|q| a * Point::new(q.x * sx, q.y * sy)).collect()
})
.collect()
}
fn points(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = match id_param(p, "id", "shape.points")? {
Some(i) => i,
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
};
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
let paths: Vec<Vec<[f64; 2]>> =
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
let types: Vec<Vec<&str>> = match &sh.geom {
Geom::Custom { paths } => paths
.iter()
.map(|cp| {
let segs = parse(&cp.d);
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
})
.collect(),
Geom::Preset { .. } => vec![],
};
Ok(json!({"id": id.0, "paths": paths, "types": types}))
}
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
/// `f` gets the segment indices of the vertices and returns whether it changed anything.
pub(crate) fn edit_path(
s: &mut Session,
id: ShapeId,
path: usize,
cmd: &'static str,
f: impl Fn(&mut Vec<Seg>, &[usize], &Xfrm) -> Result<bool>,
) -> Result<Value> {
prepare(s, id)?;
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad(cmd, "no such shape"))?;
let x = sh.xfrm.ok_or_else(|| bad(cmd, "the shape has no position"))?;
let Geom::Custom { paths } = &mut sh.geom else { return Err(bad(cmd, "not a freeform")) };
let cp = paths.get_mut(path).ok_or_else(|| bad(cmd, "no such path"))?;
let mut segs = parse(&cp.d);
let vs = vertices(&segs);
if f(&mut segs, &vs, &x)? {
cp.d = write(&segs);
refit(sh);
}
Ok(())
})?;
ok()
}
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("give `{key}`")))
}
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.movePoint")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.movePoint")?;
let to = match (f64_param(p, "x"), f64_param(p, "y")) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)),
_ => return Err(bad("shape.movePoint", "give `x` and `y` in slide points")),
};
edit_path(s, id, path, "shape.movePoint", move |segs, vs, x| {
let si = *vs.get(index).ok_or_else(|| bad("shape.movePoint", "no such point"))?;
let local = x.affine().inverse() * to;
let Some(cur) = segs.get(si).and_then(Seg::end) else { return Ok(false) };
move_vertex(segs, si, local - cur);
Ok(true)
})
}
fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.addPoint")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.addPoint")?;
let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99);
edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?;
// The closing edge of a closed outline ends at the start: split it, not the start.
let si = if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?
} else {
si
};
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
})
}
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.deletePoint")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.deletePoint")?;
edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?;
if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) }
})
}
/// The vertex of `sh` near slide point `p`: (path, index).
pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize)> {
for (pi, vs) in slide_vertices(doc, sel, sh).iter().enumerate() {
for (i, q) in vs.iter().enumerate() {
if (*q - p).hypot() <= tol {
return Some((pi, i));
}
}
}
None
}
/// The segment of `sh`'s outline near slide point `p`: (path, index of the vertex ending it, t).
pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize, f64)> {
let Geom::Custom { paths } = &sh.geom else { return None };
let x = xfrm_of(doc, sel, sh);
let local = x.affine().inverse() * p;
let mut best: Option<(f64, usize, usize, f64)> = None;
for (pi, cp) in paths.iter().enumerate() {
let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
let segs = parse(&cp.d);
let vs = vertices(&segs);
for si in 0..segs.len() {
let Some(p0) = seg_start(&segs, si) else { continue };
let at = |t: f64| -> Option<Point> {
let q = match segs.get(si)? {
Seg::L(p1) => p0.lerp(*p1, t),
Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t),
Seg::C(c1, c2, p1) => {
let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t));
a.lerp(b, t).lerp(b.lerp(c, t), t)
}
_ => return None,
};
Some(Point::new(q.x * sx, q.y * sy))
};
// The vertex that ends this segment (a closing edge ends on the start).
let end_vertex = if closes_onto_start(&segs, si) {
let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_))));
start.and_then(|m| vs.iter().position(|v| *v == m))
} else {
vs.iter().position(|v| *v == si)
};
let Some(ev) = end_vertex else { continue };
for k in 1..32 {
let t = k as f64 / 32.0;
if let Some(q) = at(t) {
let d = (q - local).hypot();
if d <= tol && best.is_none_or(|b| d < b.0) {
best = Some((d, pi, ev, t));
}
}
}
}
}
best.map(|(_, pi, ev, t)| (pi, ev, t))
}
/// Moves vertex `index` of path `path` of `id` to slide point `to` (pointer drags).
pub(crate) fn move_point_raw(s: &mut Session, id: ShapeId, path: usize, index: usize, to: Point) -> Result<Value> {
move_point(s, &json!({"id": id.0, "path": path, "index": index, "x": to.x, "y": to.y}))
}
/// Where the control handles of the vertex ending segment `si` live: (segment, point index) for
/// the incoming and the outgoing one, when those segments are cubics.
fn handle_slots(segs: &[Seg], si: usize) -> (Option<(usize, usize)>, Option<(usize, usize)>) {
let incoming = if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
} else {
Some(si)
};
let inc = incoming.filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 1));
let out = Some(si + 1).filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 0));
(inc, out)
}
fn get_pt(segs: &[Seg], slot: (usize, usize)) -> Option<Point> {
match segs.get(slot.0)? {
Seg::C(a, b, c) => [*a, *b, *c].get(slot.1).copied(),
_ => None,
}
}
fn set_pt(segs: &mut [Seg], slot: (usize, usize), p: Point) {
if let Some(s) = segs.get_mut(slot.0)
&& let Some(q) = s.points_mut().into_iter().nth(slot.1)
{
*q = p;
}
}
/// Turns segment `j` into a cubic with the same shape.
fn to_cubic(segs: &mut [Seg], j: usize) {
let Some(p0) = seg_start(segs, j) else { return };
let c = match segs.get(j).copied() {
Some(Seg::L(p1)) => Seg::C(p0.lerp(p1, 1.0 / 3.0), p0.lerp(p1, 2.0 / 3.0), p1),
Some(Seg::Q(c, p1)) => Seg::C(p0.lerp(c, 2.0 / 3.0), p1.lerp(c, 2.0 / 3.0), p1),
_ => return,
};
if let Some(s) = segs.get_mut(j) {
*s = c;
}
}
/// Sets the point type of the vertex ending segment `si`. Its segments become curves so it has
/// handles; smooth and straight points get their handles in line through the vertex.
fn set_type(segs: &mut [Seg], si: usize, kind: &str) {
let incoming = if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
} else {
Some(si)
};
if let Some(j) = incoming {
to_cubic(segs, j);
}
to_cubic(segs, si + 1);
if kind == "corner" {
return;
}
let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return };
let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return };
let (li, lo) = ((a - v).hypot(), (b - v).hypot());
let mut dir = b - a;
if dir.hypot() < 1e-9 {
// Both handles on the vertex: point them along the neighbours.
let prev = seg_start(segs, hi.0).unwrap_or(v);
let next = segs.get(ho.0).and_then(Seg::end).unwrap_or(v);
dir = next - prev;
}
let len = dir.hypot();
if len < 1e-9 {
return;
}
let u = dir / len;
let (li, lo) = if kind == "smooth" {
let m = if li + lo > 1e-9 { (li + lo) / 2.0 } else { len / 6.0 };
(m, m)
} else {
(li, lo)
};
set_pt(segs, hi, v - u * li);
set_pt(segs, ho, v + u * lo);
}
/// The type a vertex's handles show: smooth (in line, equal), straight (in line) or corner.
pub fn point_kind(segs: &[Seg], si: usize) -> &'static str {
let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return "corner" };
let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return "corner" };
let (da, db) = (a - v, b - v);
let (la, lb) = (da.hypot(), db.hypot());
if la < 1e-6 || lb < 1e-6 {
return "corner";
}
let cos = da.dot(db) / (la * lb);
if cos > -0.9998 {
"corner"
} else if (la - lb).abs() <= 0.01 * la.max(lb) {
"smooth"
} else {
"straight"
}
}
/// Moves handle `side` of the vertex ending segment `si` to `p`; the other handle follows as
/// the point's type says.
fn set_handle(segs: &mut [Seg], si: usize, out: bool, p: Point, kind: Option<&str>) -> bool {
let kind = kind.unwrap_or_else(|| point_kind(segs, si));
let (hi, ho) = handle_slots(segs, si);
let (mine, other) = if out { (ho, hi) } else { (hi, ho) };
let Some(mine) = mine else { return false };
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
set_pt(segs, mine, p);
if let Some(o) = other
&& kind != "corner"
&& let Some(q) = get_pt(segs, o)
{
let d = p - v;
let l = d.hypot();
if l > 1e-9 {
let lo = if kind == "smooth" { l } else { (q - v).hypot() };
set_pt(segs, o, v - d / l * lo);
}
}
true
}
/// Handles of vertex `index` of path `path` of `sh` in slide points: (incoming, outgoing, vertex).
pub fn slide_handles(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
sh: &Shape,
path: usize,
index: usize,
) -> Option<(Option<Point>, Option<Point>, Point)> {
let Geom::Custom { paths } = &sh.geom else { return None };
let cp = paths.get(path)?;
let x = xfrm_of(doc, sel, sh);
let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
let a = x.affine();
let to = |q: Point| a * Point::new(q.x * sx, q.y * sy);
let segs = parse(&cp.d);
let si = *vertices(&segs).get(index)?;
let v = segs.get(si).and_then(Seg::end)?;
let (hi, ho) = handle_slots(&segs, si);
Some((hi.and_then(|s| get_pt(&segs, s)).map(to), ho.and_then(|s| get_pt(&segs, s)).map(to), to(v)))
}
fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.pointType")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.pointType")?;
let kind = match str_param(p, "type") {
Some(k @ ("smooth" | "straight" | "corner")) => k.to_string(),
_ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")),
};
// A straight point's handles can be as long as each other; remember the choice for drags.
s.tool.vertex_kind = Some((path, index, kind.clone()));
edit_path(s, id, path, "shape.pointType", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?;
set_type(segs, si, &kind);
Ok(true)
})
}
fn move_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.moveHandle")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.moveHandle")?;
let out = match str_param(p, "side") {
Some("out") => true,
Some("in") => false,
_ => return Err(bad("shape.moveHandle", "`side` is in or out")),
};
let to = match (f64_param(p, "x"), f64_param(p, "y")) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)),
_ => return Err(bad("shape.moveHandle", "give `x` and `y` in slide points")),
};
let kind = str_param(p, "type").filter(|k| matches!(*k, "smooth" | "straight" | "corner")).map(str::to_string);
edit_path(s, id, path, "shape.moveHandle", move |segs, vs, x| {
let si = *vs.get(index).ok_or_else(|| bad("shape.moveHandle", "no such point"))?;
if set_handle(segs, si, out, x.affine().inverse() * to, kind.as_deref()) {
Ok(true)
} else {
Err(bad("shape.moveHandle", "that side of the point has no handle"))
}
})
}
/// The type of vertex `index` of path `path` of `sh`.
pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let si = *vertices(&segs).get(index)?;
Some(point_kind(&segs, si))
}
/// `shape.moveHandle` for pointer drags.
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
move_handle(s, p)
}
+240
View File
@@ -0,0 +1,240 @@
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
use deckcraft_color::{Rgba, SchemeSlot};
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_model::style::{PictureAdjust, PictureMode};
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"picture.recolor",
"Recolor",
["Picture Format", "Adjust", "Color"],
None,
"{preset: none|grayscale|sepia|washout|bw25|bw50|bw75|accent1Dark…accent6Dark|accent1Light…accent6Light|tx2Dark|tx2Light (see picture.recolors), ids?}",
has_selection,
recolor
),
cmd!(query "picture.recolors", "Recolor Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
presets().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
))),
cmd!(
"picture.transparentColor",
"Set Transparent Color",
["Picture Format", "Adjust", "Color"],
None,
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
has_selection,
transparent_color
),
]
}
const ACCENTS: [(&str, &str, SchemeSlot); 7] = [
("tx2", "Text color 2", SchemeSlot::Dk2),
("accent1", "Accent color 1", SchemeSlot::Accent1),
("accent2", "Accent color 2", SchemeSlot::Accent2),
("accent3", "Accent color 3", SchemeSlot::Accent3),
("accent4", "Accent color 4", SchemeSlot::Accent4),
("accent5", "Accent color 5", SchemeSlot::Accent5),
("accent6", "Accent color 6", SchemeSlot::Accent6),
];
/// (id, label), in PowerPoint's gallery order: colour modes, then dark and light variations.
pub fn presets() -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = [
("none", "No Recolor"),
("grayscale", "Grayscale"),
("sepia", "Sepia"),
("washout", "Washout"),
("bw25", "Black and White: 25%"),
("bw50", "Black and White: 50%"),
("bw75", "Black and White: 75%"),
]
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect();
for (id, label, _) in ACCENTS {
v.push((format!("{id}Dark"), format!("{label} Dark")));
}
for (id, label, _) in ACCENTS {
v.push((format!("{id}Light"), format!("{label} Light")));
}
v
}
/// Set `a`'s recolour to `preset`, clearing any earlier one. `false` for an unknown preset.
pub fn apply(a: &mut PictureAdjust, preset: &str) -> bool {
let washed = a.brightness == 0.7 && a.contrast == -0.7;
let mut n = PictureAdjust { grayscale: false, duotone: None, bilevel: None, ..a.clone() };
if washed {
n.brightness = 0.0;
n.contrast = 0.0;
}
let rgb = |r, g, b| ColorRef::rgb(Rgba::rgb(r, g, b));
match preset {
"none" => {}
"grayscale" => n.grayscale = true,
// PowerPoint's sepia is a duotone from dark brown to light cream.
"sepia" => n.duotone = Some((rgb(0x40, 0x2A, 0x10), rgb(0xF2, 0xE3, 0xC6))),
// Washout is PowerPoint's +70% brightness, −70% contrast.
"washout" => {
n.brightness = 0.7;
n.contrast = -0.7;
}
"bw25" => n.bilevel = Some(0.25),
"bw50" => n.bilevel = Some(0.5),
"bw75" => n.bilevel = Some(0.75),
other => {
let Some((slot, dark)) = ACCENTS.iter().find_map(|(id, _, slot)| {
other.strip_prefix(id).and_then(|rest| match rest {
"Dark" => Some((*slot, true)),
"Light" => Some((*slot, false)),
_ => None,
})
}) else {
return false;
};
let c = ColorRef::scheme(slot);
n.duotone = Some(if dark { (rgb(0, 0, 0), c) } else { (c, rgb(0xFF, 0xFF, 0xFF)) });
}
}
*a = n;
true
}
fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
let preset = str_param(p, "preset").ok_or_else(|| bad("picture.recolor", "missing `preset`"))?.to_string();
if !presets().iter().any(|(id, _)| *id == preset) {
return Err(bad("picture.recolor", format!("unknown preset `{preset}`; see picture.recolors")));
}
edit_shapes(s, p, "picture.recolor", |sh| {
if let ShapeKind::Picture { fill } = &mut sh.kind {
apply(&mut fill.adjust, &preset);
}
Ok(())
})
}
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(at) = p.get("at") {
let (id, c) = picture_pixel_at(s, p, at)?;
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
if let ShapeKind::Picture { fill } = &mut sh.kind {
fill.adjust.clear_color = Some(c);
}
Ok(())
});
}
let c = if bool_or(p, "none", false) {
None
} else {
let hex = str_param(p, "color").ok_or_else(|| bad("picture.transparentColor", "give `color` (#RRGGBB) or `none: true`"))?;
Some(Rgba::from_hex(hex.trim_start_matches('#')).ok_or_else(|| bad("picture.transparentColor", format!("bad colour `{hex}`")))?)
};
edit_shapes(s, p, "picture.transparentColor", |sh| {
if let ShapeKind::Picture { fill } = &mut sh.kind {
fill.adjust.clear_color = c;
}
Ok(())
})
}
/// The topmost target picture under slide point `at` and the colour of its own pixel there: the
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
/// `clear_color` is compared with.
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
const CMD: &str = "picture.transparentColor";
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
return Err(bad(CMD, "`at` is [x, y] in slide units"));
};
let mut ids = targets(s, p)?;
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
// Same order as the hit-test: later in the shape list is in front, and a group's children sit
// where the group is. The selection order says nothing about what is on top.
ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id)));
for id in ids {
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
let ShapeKind::Picture { fill } = &sh.kind else { continue };
// The camera turns the picture where it is drawn, so the click is mapped back through it too.
let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY);
let local = (to_slide * turn).inverse() * Point::new(x, y);
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
continue;
}
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
};
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
let dest = deckcraft_render::crop_dest(inner, fill.crop);
let img =
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
continue;
}
let (w, h) = (img.width(), img.height());
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
let Some(c) = img.get_pixel_checked(px, py) else { continue };
// A transparent pixel keeps a colour nobody sees (often black): clearing it would clear
// that colour where it is drawn.
if c[3] == 0 {
return Err(bad(CMD, "that spot of the picture is already transparent"));
}
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
}
Err(bad(CMD, "the point is not on a selected picture"))
}
/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger
/// path is drawn later, so in front.
fn stack_path(shapes: &[Shape], id: ShapeId) -> Option<Vec<usize>> {
for (i, sh) in shapes.iter().enumerate() {
if sh.id == id {
return Some(vec![i]);
}
if let ShapeKind::Group { children, .. } = &sh.kind
&& let Some(mut rest) = stack_path(children, id)
{
rest.insert(0, i);
return Some(rest);
}
}
None
}
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
/// space.
fn placed<'a>(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &'a [Shape],
id: ShapeId,
to_slide: Affine,
depth: usize,
) -> Option<(&'a Shape, Xfrm, Affine)> {
if depth > 64 {
return None;
}
for sh in shapes {
let x = xfrm_of(doc, sel, sh);
if sh.id == id {
return Some((sh, x, to_slide * x.affine()));
}
if let ShapeKind::Group { children, child } = &sh.kind {
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
return Some(found);
}
}
}
None
}
+400
View File
@@ -0,0 +1,400 @@
//! Home ▸ New Slide ▸ Reuse Slides: insert slides from another presentation.
//!
//! The slides take the destination's look by default (each one moves to the destination layout
//! with the same name, else the same kind); with `keepFormatting` the source master and its
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"slide.reuse",
"Reuse Slides…",
["Home", "Slides", "New Slide"],
None,
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
has_doc,
reuse
)]
}
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
fn remap(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
move |id: &mut MediaId| {
if let Some(to) = map.get(id) {
*id = *to;
}
}
}
fn shape_ids(shapes: &[deckcraft_model::Shape]) -> Vec<ShapeId> {
let mut v = vec![];
deckcraft_model::walk(shapes, &mut |sh, _| v.push(sh.id));
v
}
/// Point connector ends at the renumbered shapes.
fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap<ShapeId, ShapeId>, depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
if let Some((id, _)) = c.as_mut() {
*id = map.get(id).copied().unwrap_or(*id);
}
}
}
if let Some(ch) = sh.children_mut() {
remap_connectors(ch, map, depth + 1);
}
}
}
/// Point a link to a reused slide at its copy (`map`: source → destination).
fn relink(h: &mut Option<Hyperlink>, map: &HashMap<SlideId, SlideId>) {
let Some(h) = h else { return };
if let Action::Slide { slide } = &mut h.action {
*slide = map.get(slide).copied().unwrap_or(*slide);
}
if let Some(z) = &mut h.zoom_return {
*z = map.get(z).copied().unwrap_or(*z);
}
}
fn relink_run(r: &mut RunProps, map: &HashMap<SlideId, SlideId>) {
relink(&mut r.link, map);
relink(&mut r.hover, map);
}
fn relink_text(t: &mut TextBody, map: &HashMap<SlideId, SlideId>) {
for lv in t.list_style.levels.iter_mut().flatten() {
relink_run(&mut lv.run, map);
}
for p in &mut t.paragraphs {
relink_run(&mut p.def_props, map);
relink_run(&mut p.end_props, map);
for r in &mut p.runs {
relink_run(&mut r.props, map);
}
}
}
/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, SlideId>, depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
relink(&mut sh.click, map);
relink(&mut sh.hover, map);
if let Some(t) = &mut sh.text {
relink_text(t, map);
}
if let ShapeKind::Table(t) = &mut sh.kind {
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
relink_text(&mut c.text, map);
}
}
if let Some(ch) = sh.children_mut() {
relink_shapes(ch, map, depth + 1);
}
}
}
/// Fresh shape ids for `shapes`, and the old → new map.
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
let old = shape_ids(shapes);
super::edit::reid("slide.reuse", doc, shapes)?;
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
remap_connectors(shapes, &map, 0);
Ok(map)
}
/// The destination layout a reused slide moves to: same name, else same kind, else the first.
fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId {
let all = || doc.masters.iter().flat_map(|m| m.layouts.iter());
all()
.find(|l| !name.is_empty() && l.name.eq_ignore_ascii_case(name))
.or_else(|| all().find(|l| l.kind == kind))
.or_else(|| all().next())
.map(|l| l.id)
.unwrap_or_default()
}
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)?;
let n = src.slides.len();
let picked: Vec<usize> = match p.get("slides") {
Some(Value::Array(a)) => {
let mut v = vec![];
for x in a {
let i = x.as_u64().map(|i| i as usize).filter(|i| *i < n);
let i = i.ok_or_else(|| bad("slide.reuse", format!("{name} has {n} slides; {x} isn't one of them (indices count from 0)")))?;
if !v.contains(&i) {
v.push(i);
}
}
v
}
Some(Value::String(t)) if !t.trim().is_empty() => super::file::parse_slide_range(t, n)?,
_ => (0..n).collect(),
};
if picked.is_empty() {
return Err(bad("slide.reuse", format!("{name} has no slides to reuse")));
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
// 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.
let mut layout_map: HashMap<LayoutId, LayoutId> = HashMap::new();
if keep {
let mut masters_done: Vec<MasterId> = vec![];
for &i in &picked {
let Some((m, _)) = src.slides.get(i).and_then(|sl| src.master_for(sl)) else { continue };
if masters_done.contains(&m.id) {
continue;
}
masters_done.push(m.id);
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(doc, &src, &used, &mut media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
renumber(doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
layout_map.insert(l.id, new);
l.id = new;
renumber(doc, &mut l.shapes)?;
}
doc.masters.push(Arc::new(m));
}
}
let mut copies: Vec<Slide> = vec![];
// Source slide → its copy, so links between reused slides follow (forward ones too).
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
for &i in &picked {
let Some(from) = src.slides.get(i) else { continue };
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
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(doc, &mut sl.shapes)?;
for a in &mut sl.animations {
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
}
sl.layout = match layout_map.get(&sl.layout) {
Some(l) => *l,
None => {
let (lname, kind) =
src.layout(from.layout).map(|(_, l)| (l.name.clone(), l.kind)).unwrap_or_else(|| (String::new(), Default::default()));
matching_layout(doc, &lname, kind)
}
};
slide_map.insert(from.id, sl.id);
copies.push(sl);
}
let mut new_ids = vec![];
for (k, mut sl) in copies.into_iter().enumerate() {
relink_shapes(&mut sl.shapes, &slide_map, 0);
relink_text(&mut sl.notes, &slide_map);
new_ids.push(sl.id);
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
}
sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1));
sel.slides = new_ids.clone();
sel.shapes.clear();
sel.text = None;
Ok(json!({"slides": new_ids.len(), "ids": new_ids, "from": name, "keepFormatting": keep}))
})
}
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
/// `doc` are shared; a linked item keeps its link).
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
for &id in used {
if map.contains_key(&id) {
continue;
}
let Some(m) = src.media(id) else { continue };
let new = if m.link.is_some() {
let mut item = m.clone();
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let new = item.id;
doc.media.push(item);
new
} else {
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
};
map.insert(id, new);
}
Ok(())
}
#[cfg(test)]
mod tests {
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use serde_json::json;
use super::*;
/// 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]
fn reused_slides_bring_every_media_reference() {
let mut src = defaults::new_presentation(None);
let blobs: Vec<Vec<u8>> = (0..8u8).map(|i| vec![0xD0, i, i, i]).collect();
let ids: Vec<MediaId> = blobs
.iter()
.enumerate()
.map(|(i, b)| src.add_media(&format!("m{i}.bin"), "application/octet-stream", b.clone()).expect("media"))
.collect();
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
let mut text = TextBody::from_text("bullet");
if let Some(p) = text.paragraphs.first_mut() {
p.props.bullet = Some(Bullet::Picture { media: id(6) });
}
let slide = Arc::make_mut(src.slides.first_mut().expect("slide"));
slide.shapes = vec![
Shape {
id: ShapeId(9001),
kind: ShapeKind::Picture {
fill: PictureFill {
media: id(0),
artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1) }),
..Default::default()
},
},
..Default::default()
},
Shape {
id: ShapeId(9002),
kind: ShapeKind::Media(MediaClip { media: id(2), poster: Some(id(3)), ..Default::default() }),
..Default::default()
},
Shape {
id: ShapeId(9003),
click: Some(Hyperlink {
action: Action::NextSlide,
tooltip: String::new(),
highlight_click: false,
sound: Some(id(5)),
stop_sound: false,
zoom_return: None,
}),
text: Some(text),
..Default::default()
},
];
slide.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: id(4), ..Default::default() }) });
slide.transition = Some(deckcraft_model::anim::Transition { sound: Some(id(7)), ..Default::default() });
let bytes = deckcraft_format::save(&src).expect("save");
let mut s = Session::with_new();
let own = vec![1u8, 2, 3];
let own_id = s.edit(|doc, _| Ok(doc.add_media("own.bin", "application/octet-stream", own.clone()).expect("media"))).expect("own media");
s.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0]})).expect("reuse");
let doc = &s.doc().expect("doc").doc;
assert_eq!(doc.media(own_id).map(|m| m.data.as_slice() == own.as_slice()), Some(true), "own media untouched");
let reused = doc.slides.get(1).expect("reused slide");
let got: Vec<Vec<u8>> =
media_refs::slide_media(reused).iter().map(|m| doc.media(*m).map(|m| m.data.as_ref().clone()).unwrap_or_default()).collect();
let mut got = got;
got.sort();
let mut want = blobs.clone();
want.sort();
assert_eq!(got, want, "every reference resolves to its own copied bytes");
}
fn link_to(slide: SlideId) -> Hyperlink {
Hyperlink {
action: Action::Slide { slide },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}
}
fn linked_text(slide: SlideId) -> TextBody {
let mut text = TextBody::from_text("go");
if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
r.props.link = Some(link_to(slide));
}
text
}
/// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
#[test]
fn reused_slides_link_to_each_others_copies() {
let mut src = defaults::new_presentation(None);
let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
for _ in 0..2 {
let mut sl = template.clone();
sl.id = SlideId(src.alloc_id().expect("id"));
src.slides.push(Arc::new(sl));
}
let ids: Vec<SlideId> = src.slides.iter().map(|s| s.id).collect();
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
sl.shapes = vec![
Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
];
}
if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
}
let bytes = deckcraft_format::save(&src).expect("save");
let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).expect("new");
}
let out = s
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
.expect("reuse");
let new: Vec<SlideId> = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
let doc = &s.doc().expect("doc").doc;
let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
let first = copy(0);
let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
let run_link = first
.shapes
.get(1)
.and_then(|sh| sh.text.as_ref())
.and_then(|t| t.paragraphs.first())
.and_then(|p| p.runs.first())
.and_then(|r| r.props.link.as_ref())
.map(|h| h.action.clone());
assert_eq!(run_link, to(1), "text link follows the copy");
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
}
}
+595 -44
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Comment;
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session, UiRequest};
use crate::{Result, Session, Target, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -15,8 +15,51 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete),
cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve),
cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list),
cmd!(
noundo "comment.next",
"Next Comment",
["Review", "Comments"],
None,
"{slide?, index?} → {slide, index, text}",
has_slide,
|s, p| neighbour(s, p, true)
),
cmd!(
noundo "comment.prev",
"Previous Comment",
["Review", "Comments"],
None,
"{slide?, index?} → {slide, index, text}",
has_slide,
|s, p| neighbour(s, p, false)
),
cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape?, row?, col?, notes?, para, start, end, word, lang, suggestions}] (shapes, table cells and notes; repeated words as {repeated, deleteFrom}; runs marked noProof skipped; each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling),
cmd!(
"review.spellingChange",
"Change Spelling",
["Review", "Proofing"],
None,
"{shape, word, to, all?, para?, start?, row?, col?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)",
has_doc,
spelling_change
),
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
cmd!(
"review.replaceWord",
"Insert Synonym",
[],
None,
"{with}: replace the selected word (or the word at the caret) while editing text",
has_text,
insert_synonym
),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
let i = s.doc()?.selection.slide;
@@ -27,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Up Slide Show…",
["Slide Show", "Set Up"],
None,
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}",
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?, custom?: name}",
has_doc,
setup
),
@@ -43,24 +86,33 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn now() -> String {
/// Seconds since the Unix epoch: the system clock, or the browser's on wasm (which has no `SystemTime`).
fn now_secs() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (y, m, d) = super::design::civil((secs / 86_400) as i64);
let t = secs % 86_400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
{
String::new()
(js_sys::Date::now() / 1000.0).max(0.0) as u64
}
}
/// A UTC timestamp as saved on comments: `2026-10-10T13:10:35Z`.
fn iso_utc(secs: u64) -> String {
let (y, m, d) = super::design::civil((secs / 86_400) as i64);
let t = secs % 86_400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
}
fn now() -> String {
iso_utc(now_secs())
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string();
let author = s.prefs.author.clone();
let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied()));
let shape = id_param(p, "id", "comment.add")?.or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied()));
let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0));
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?);
@@ -85,7 +137,14 @@ fn reply(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let all = bool_or(p, "all", false);
let i = usize_param(p, "index");
let i = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_u64()
.and_then(|v| usize::try_from(v).ok())
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
),
};
s.edit(|doc, sel| {
if all {
for sl in &mut doc.slides {
@@ -123,6 +182,51 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default())
}
/// Flat `(slide, comment index)` list in deck order.
fn comment_threads(doc: &deckcraft_model::Presentation) -> Vec<(usize, usize)> {
let mut out = Vec::new();
for (si, sl) in doc.slides.iter().enumerate() {
for ci in 0..sl.comments.len() {
out.push((si, ci));
}
}
out
}
/// Step to the next or previous comment. With `{slide, index}` matching a thread, move one step
/// and stop at the ends. Without `index`, next is the first thread on or after `slide` (the
/// current slide when omitted) and previous is the last thread strictly before it.
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"))
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
}
let pos = index.and_then(|i| threads.iter().position(|&(sl, c)| sl == slide && c == i));
let target = match (pos, forward) {
(Some(i), true) => threads.get(i + 1).copied(),
(Some(i), false) => i.checked_sub(1).and_then(|j| threads.get(j).copied()),
(None, true) => threads.iter().copied().find(|(sl, _)| *sl >= slide),
(None, false) => threads.iter().copied().rev().find(|(sl, _)| *sl < slide),
};
let Some((slide, index)) = target else {
return Err(bad(cmd, "no more comments"));
};
s.select(|doc, sel| {
sel.slide = slide;
sel.slides = doc.slides.get(slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.target = Target::Slides;
})?;
let text = s.doc()?.doc.slides.get(slide).and_then(|sl| sl.comments.get(index)).map(|c| c.text.clone()).unwrap_or_default();
Ok(json!({"slide": slide, "index": index, "text": text}))
}
fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let mut out = vec![];
@@ -168,52 +272,271 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(out))
}
fn spelling(s: &mut Session, _p: &Value) -> Result<Value> {
// A small built-in list of frequent misspellings (full dictionaries come later).
const COMMON: &[&str] = &[
"teh",
"recieve",
"seperate",
"occured",
"untill",
"wich",
"becuase",
"definately",
"thier",
"alot",
"accomodate",
"acheive",
"adress",
"begining",
"beleive",
"calender",
"commited",
"enviroment",
"goverment",
"independant",
"neccessary",
"occassion",
"publically",
"reccomend",
"tommorow",
"wierd",
];
/// Frequent misspellings, used when no dictionary for the language is installed.
const COMMON: &[&str] = &[
"teh",
"recieve",
"seperate",
"occured",
"untill",
"wich",
"becuase",
"definately",
"thier",
"alot",
"accomodate",
"acheive",
"adress",
"begining",
"beleive",
"calender",
"commited",
"enviroment",
"goverment",
"independant",
"neccessary",
"occassion",
"publically",
"reccomend",
"tommorow",
"wierd",
];
/// The language a check uses: the `lang` parameter, else the first run that says, else US English.
fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String {
if let Some(l) = str_param(p, "lang") {
return l.to_string();
}
let mut found = None;
for sl in &doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if found.is_none()
&& let Some(t) = &sh.text
{
found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone());
}
});
}
found.unwrap_or_else(|| "en-US".into())
}
fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let default = check_lang(&st.doc, p);
let ignore: Vec<String> =
p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default();
let mut dicts: std::collections::HashMap<String, Option<Arc<crate::spell::Dictionary>>> = Default::default();
let mut out = vec![];
// Each misspelt word of `para`, reported with `at` (where the paragraph is) merged in.
let mut check = |para: &deckcraft_model::Paragraph, at: Value, out: &mut Vec<Value>| {
for w in crate::spell::repeated(para, &default) {
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "repeated": true, "deleteFrom": w.repeated, "suggestions": []});
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
o.extend(a.clone());
}
out.push(v);
}
let rep: Vec<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;
}
let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone();
let bad = match &dict {
Some(d) => !crate::spell::is_correct(d, &w.text),
None => COMMON.contains(&w.text.to_lowercase().as_str()),
};
if bad {
let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default();
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug});
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
o.extend(a.clone());
}
out.push(v);
}
}
};
for (i, sl) in st.doc.slides.iter().enumerate() {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if let Some(t) = &sh.text {
for w in t.text().split(|c: char| !c.is_alphanumeric() && c != '\'') {
if COMMON.contains(&w.to_lowercase().as_str()) {
out.push(json!({"slide": i, "shape": sh.id, "word": w}));
for (pi, para) in t.paragraphs.iter().enumerate() {
check(para, json!({"slide": i, "shape": sh.id, "para": pi}), &mut out);
}
}
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
for (r, row) in tb.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().filter(|(_, c)| !c.is_covered()) {
for (pi, para) in cell.text.paragraphs.iter().enumerate() {
check(para, json!({"slide": i, "shape": sh.id, "row": r, "col": c, "para": pi}), &mut out);
}
}
}
}
});
// Speaker notes, a paragraph per line.
for (pi, para) in sl.notes.paragraphs.iter().enumerate() {
check(para, json!({"slide": i, "notes": true, "para": pi}), &mut out);
}
}
Ok(Value::Array(out))
}
fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?;
crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?;
Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())}))
}
/// 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;
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
}
fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string();
let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string();
let all = bool_or(p, "all", false);
let shape = p.get("shape").and_then(Value::as_u64);
if shape.is_none() && !all {
return Err(bad("review.spellingChange", "give `shape`, or `all: true`"));
}
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
let end = start + word.chars().count();
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
let slide = {
let st = s.doc()?;
let found = st.doc.slides.iter().position(|sl| {
deckcraft_model::find_shape(&sl.shapes, id)
.and_then(|sh| match (cell, &sh.kind) {
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell(r, c)?.text.paragraphs.get(para),
(Some(_), _) => None,
(None, _) => sh.text.as_ref()?.paragraphs.get(para),
})
.is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::<String>() == word)
});
found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))?
};
s.edit(|doc, _| {
let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?;
let pp = match (cell, &mut sh.kind) {
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell_mut(r, c).and_then(|cl| cl.text.paragraphs.get_mut(para)),
(Some(_), _) => None,
(None, _) => sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)),
};
if let Some(pp) = pp {
replace_chars(pp, start, end, &to);
}
Ok(())
})?;
return Ok(json!({"changed": 1}));
}
// Nothing to change makes no undo step.
let mut any = false;
let st = s.doc()?;
for sl in &st.doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id))
&& let Some(t) = &sh.text
{
any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0);
}
});
}
if !any {
return Ok(json!({"changed": 0}));
}
let changed = s.edit(|doc, _| {
let mut n = 0;
let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64());
for sl in &mut doc.slides {
let mut hit = false;
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str()));
});
if !hit || (!all && n > 0) {
continue;
}
walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| {
if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) {
return;
}
let Some(t) = sh.text.as_mut() else { return };
for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) {
if !all && n > 0 {
break;
}
n += replace_word(&mut r.text, &word, &to, all);
}
});
}
Ok(n)
})?;
Ok(json!({"changed": changed}))
}
/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose
/// the rest of the range).
fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) {
let mut at = 0;
let mut placed = false;
for r in &mut p.runs {
let n = r.char_len();
let (a, b) = (start.max(at), end.min(at + n));
if a < b && r.kind == deckcraft_model::text::RunKind::Text {
let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i);
let (x, y) = (byte(a - at), byte(b - at));
r.text.replace_range(x..y, if placed { "" } else { to });
placed = true;
}
at += n;
}
p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty()));
}
fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
if depth > 64 {
return;
}
for sh in shapes.iter_mut() {
f(sh);
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
walk_text_mut(children, depth + 1, f);
}
}
}
fn dictionary_info(s: &mut Session, p: &Value) -> Result<Value> {
let lang = match s.doc() {
Ok(st) => check_lang(&st.doc, p),
Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(),
};
let dirs = crate::spell::search_dirs();
let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string());
let available = crate::spell::dictionary(&lang).is_some();
Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()}))
}
fn start(s: &mut Session, from: usize) -> Result<Value> {
s.ui_requests.push(UiRequest::StartShow { from });
Ok(json!({"from": from}))
@@ -240,11 +563,25 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = bool_param(p, "useTimings") {
sh.use_timings = v;
}
if let Some(v) = bool_param(p, "showMediaControls") {
sh.show_media_controls = v;
}
match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) {
(Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)),
(None, None) if p.get("all").is_some() => sh.range = None,
_ => {}
}
if p.get("all").and_then(Value::as_bool) == Some(true) {
sh.range = None;
sh.custom_show = None;
}
if p.get("custom").is_some() {
let name = p.get("custom").and_then(Value::as_str).unwrap_or("");
sh.custom_show = if name.is_empty() { None } else { Some(name.to_string()) };
if sh.custom_show.is_some() {
sh.range = None;
}
}
Ok(serde_json::to_value(&*sh).unwrap_or_default())
})
}
@@ -264,3 +601,217 @@ fn custom_show(s: &mut Session, p: &Value) -> Result<Value> {
})?;
ok()
}
/// The selected text when it lies in one paragraph, else the word at the caret.
fn word_under_caret(s: &Session) -> Option<String> {
let st = s.doc().ok()?;
let t = st.selection.text.clone()?;
let body = super::text::body_of(st, &t)?;
let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) };
if a.0 != b.0 {
return None;
}
let text = body.paragraphs.get(a.0)?.text();
let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect();
let w = w.trim().to_string();
(!w.is_empty()).then_some(w)
}
fn thesaurus(s: &mut Session, p: &Value) -> Result<Value> {
let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default();
let senses = crate::thesaurus::lookup(&word);
Ok(json!({"word": word, "senses": senses}))
}
fn insert_synonym(s: &mut Session, p: &Value) -> Result<Value> {
let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string();
if with.chars().count() > 200 {
return Err(bad("review.replaceWord", "too long"));
}
let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret);
if collapsed {
s.execute("text.selectWord", &json!({}))?;
}
s.execute("text.insert", &json!({"text": with}))
}
fn comparing(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
}
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
if let Some(data) = str_param(p, "data") {
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?;
return Ok((path.to_string(), bytes));
}
Err(bad("review.compare", "give `path` or `data`"))
}
fn revisions_json(st: &crate::DocState) -> Result<Value> {
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = revised_bytes(p)?;
let revised = super::file::open_presentation(&name, &bytes)?;
let st = s.doc_mut()?;
let by_position = crate::compare::unrelated(&st.doc, &revised);
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
revisions_json(s.doc()?)
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.accept", "give `index` or `all`"));
}
s.edit(|doc, sel| {
let mut accepted = 0usize;
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
for _ in 0..limit {
let changes = crate::compare::diff(doc, &rev, by_pos);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
changes.into_iter().nth(index.unwrap_or(0))
};
let Some(c) = c else { break };
let Some(at) = crate::compare::accept(doc, &rev, &c)? else { break };
accepted += 1;
sel.slide = at;
sel.shapes.clear();
sel.text = None;
if !all {
break;
}
}
if accepted == 0 {
return Err(bad("review.accept", "no such revision"));
}
Ok(json!({"accepted": accepted}))
})
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::Session;
#[test]
fn previous_and_next_comment_walk_the_deck() {
let mut s = Session::with_new();
let err = s.execute("comment.next", &json!({})).unwrap_err();
assert!(err.to_string().contains("no comments"), "{err}");
s.execute("comment.add", &json!({"text": "on 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": "on second a"})).unwrap();
s.execute("comment.add", &json!({"text": "on second b"})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.slide, 2);
let err = s.execute("comment.next", &json!({})).unwrap_err();
assert!(err.to_string().contains("no more comments"), "{err}");
let undo_before = s.doc().unwrap().history.undo.len();
let back = s.execute("comment.prev", &json!({})).unwrap();
assert_eq!(back["slide"], 1);
assert_eq!(back["index"], 1);
assert_eq!(back["text"], "on second b");
assert_eq!(s.doc().unwrap().selection.slide, 1);
assert!(s.doc().unwrap().selection.shapes.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
let prev = s.execute("comment.prev", &json!({"slide": 1, "index": 1})).unwrap();
assert_eq!(prev["index"], 0);
assert_eq!(prev["text"], "on second a");
let first = s.execute("comment.prev", &json!({"slide": prev["slide"], "index": prev["index"]})).unwrap();
assert_eq!(first["slide"], 0);
assert_eq!(first["text"], "on first");
let err = s.execute("comment.prev", &json!({"slide": 0, "index": 0})).unwrap_err();
assert!(err.to_string().contains("no more comments"), "{err}");
let next = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(next["slide"], 1);
assert_eq!(next["text"], "on second a");
s.execute("slide.go", &json!({"index": 0})).unwrap();
let here = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(here["text"], "on first");
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_utc(1_791_637_835), "2026-10-10T13:10:35Z");
}
#[test]
fn the_clock_is_not_empty() {
// On wasm this used to return an empty string, so browser comments were saved undated.
assert!(now().starts_with("20"), "{}", now());
}
#[test]
fn show_media_controls_follow_setup() {
let mut s = Session::with_new();
assert!(s.doc().expect("doc").doc.show.show_media_controls);
let off = s.execute("show.setup", &json!({"showMediaControls": false})).expect("off");
assert_eq!(off["showMediaControls"], false);
assert_eq!(off["useTimings"], true, "other setup flags stay");
let timings = s.execute("show.setup", &json!({"useTimings": false})).expect("timings");
assert_eq!(timings["showMediaControls"], false);
assert_eq!(timings["useTimings"], false);
s.execute("show.setup", &json!({"showMediaControls": true})).expect("on");
assert!(s.doc().expect("doc").doc.show.show_media_controls);
}
#[test]
fn setup_remembers_a_custom_show() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("show.customShow", &json!({"name": "Short", "slides": [0, 1]})).unwrap();
let v = s.execute("show.setup", &json!({"custom": "Short"})).unwrap();
assert_eq!(v.get("customShow").and_then(Value::as_str), Some("Short"));
assert!(s.active().unwrap().doc.show.range.is_none());
s.execute("show.setup", &json!({"custom": ""})).unwrap();
assert!(s.active().unwrap().doc.show.custom_show.is_none());
}
#[test]
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "a"})).unwrap();
s.execute("comment.add", &json!({"text": "b"})).unwrap();
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
let r = s.execute("comment.delete", &json!({"index": bad_index}));
assert!(r.is_err(), "index {bad_index} gave {r:?}");
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
}
s.execute("comment.delete", &json!({})).unwrap();
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
}
}
+432
View File
@@ -0,0 +1,432 @@
//! Rich text on the system clipboard, as HTML.
//!
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
const MAX_HTML: usize = 1 << 20;
const MAX_PARAS: usize = 2_000;
const MAX_RUNS: usize = 10_000;
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
pub fn body_to_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
if i > 0 {
out.push_str("<br>");
}
out.push_str("<p>");
for r in &p.runs {
let (open, close) = tags(&r.props);
out.push_str(&open);
match &r.kind {
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
_ => push_escaped(&mut out, &r.text),
}
out.push_str(&close);
if out.len() > MAX_HTML {
break;
}
}
out.push_str("</p>");
}
out
}
fn tags(props: &RunProps) -> (String, String) {
let mut open = String::new();
let mut close = String::new();
if props.bold == Some(true) {
open.push_str("<b>");
close.insert_str(0, "</b>");
}
if props.italic == Some(true) {
open.push_str("<i>");
close.insert_str(0, "</i>");
}
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
open.push_str("<u>");
close.insert_str(0, "</u>");
}
(open, close)
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
}
/// The formatted text of an HTML fragment, or `None` when it has no text.
///
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
/// says bold, italic or underline. Other tags are ignored. The result is capped.
pub fn html_to_body(html: &str) -> Option<TextBody> {
if html.len() > MAX_HTML {
return None;
}
let bytes = html.as_bytes();
let mut i = 0;
let mut bold = 0i32;
let mut italic = 0i32;
let mut underline = 0i32;
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
let mut runs = 0usize;
let mut skip = 0i32;
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
if bytes[i] == b'<' {
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
let raw = &html[i + 1..end];
i = (end + 1).min(bytes.len());
let token = raw.trim();
if token.starts_with('!') || token.starts_with('?') {
continue;
}
let closing = token.starts_with('/');
let name_src = token.trim_start_matches('/').trim();
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
let name = name_src[..name_end].to_ascii_lowercase();
if name == "script" || name == "style" || name == "head" {
if closing {
skip = (skip - 1).max(0);
} else if !token.ends_with('/') {
skip += 1;
}
continue;
}
if skip > 0 {
continue;
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty() {
let search_start = stack.len().saturating_sub(64);
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
let pos = search_start + offset;
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
}
if is_block(&name) {
new_para(&mut paragraphs);
}
continue;
}
if is_break(&name) {
new_para(&mut paragraphs);
continue;
}
let (db, di, du) = tag_style(&name, name_src);
if db + di + du > 0 {
bold += db;
italic += di;
underline += du;
if !token.ends_with('/') && stack.len() < 128 {
stack.push((tag_kind(&name), db, di, du));
} else {
bold -= db;
italic -= di;
underline -= du;
}
}
continue;
}
if skip > 0 {
i += 1;
continue;
}
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
let mut text = decode_text(&html[i..end]);
i = end;
// A space at the start of a paragraph, or after a space, does not render in HTML.
let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' '));
if after_space {
text = text.trim_start_matches(' ').to_string();
}
if text.is_empty() {
continue;
}
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
}
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
paragraphs.pop();
}
trim_end_space(&mut paragraphs);
if paragraphs.iter().all(Paragraph::is_empty) {
return None;
}
for p in &mut paragraphs {
p.normalize();
}
Some(TextBody { paragraphs, ..Default::default() })
}
fn tag_kind(name: &str) -> &'static str {
match name {
"b" | "strong" => "b",
"i" | "em" => "i",
"u" => "u",
"span" | "font" => "span",
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
_ => "",
}
}
fn is_break(name: &str) -> bool {
name == "br" || name == "hr"
}
fn is_block(name: &str) -> bool {
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
}
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
let mut bold = i32::from(name == "b" || name == "strong");
let mut italic = i32::from(name == "i" || name == "em");
let mut underline = i32::from(name == "u");
if let Some(style) = attr(src, "style") {
let s = style.to_ascii_lowercase();
if let Some(on) = weight_bold(&s) {
bold = i32::from(on);
}
if s.contains("font-style:italic") || s.contains("font-style: italic") {
italic = 1;
}
if s.contains("text-decoration:underline")
|| s.contains("text-decoration: underline")
|| s.contains("text-decoration-line:underline")
|| s.contains("text-decoration-line: underline")
{
underline = 1;
}
}
(bold, italic, underline)
}
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
fn weight_bold(style: &str) -> Option<bool> {
let at = style.find("font-weight")?;
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
if rest.starts_with("bold") {
return Some(true);
}
if rest.starts_with("normal") || rest.starts_with("lighter") {
return Some(false);
}
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let weight: u32 = digits.parse().ok()?;
Some(weight >= 600)
}
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = after.get(quote.len_utf8()..)?;
let end = rest.find(quote)?;
rest.get(..end)
}
fn new_para(paragraphs: &mut Vec<Paragraph>) {
if paragraphs.len() >= MAX_PARAS {
return;
}
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
trim_end_space(paragraphs);
paragraphs.push(Paragraph::default());
}
}
/// Whitespace at the end of a paragraph does not render in HTML.
fn trim_end_space(paragraphs: &mut [Paragraph]) {
if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) {
let len = run.text.trim_end_matches(' ').len();
run.text.truncate(len);
}
}
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
if *runs >= MAX_RUNS {
return;
}
let Some(p) = paragraphs.last_mut() else { return };
let mut props = RunProps::default();
if bold {
props.bold = Some(true);
}
if italic {
props.italic = Some(true);
}
if underline {
props.underline = Some("sng".into());
}
p.runs.push(Run::with(text, props));
*runs += 1;
}
/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space.
fn collapse_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if matches!(c, ' ' | '\t' | '\n' | '\r') {
if !space {
out.push(' ');
}
space = true;
} else {
out.push(c);
space = false;
}
}
out
}
fn decode_text(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find('&') {
out.push_str(&collapse_space(&rest[..at]));
rest = &rest[at..];
let search_window = rest.len().min(32);
let Some(end) = rest[..search_window].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
};
let entity = &rest[1..end];
let decoded = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => entity.strip_prefix('#').and_then(numeric_char),
};
if let Some(c) = decoded {
out.push(c);
rest = &rest[end + 1..];
} else {
// Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
out.push('&');
rest = &rest[1..];
}
}
if !rest.is_empty() && !rest.starts_with('&') {
out.push_str(&collapse_space(rest));
}
out
}
fn numeric_char(digits: &str) -> Option<char> {
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
}
/// Do the HTML's words and the paste's plain text say the same thing?
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
let a = fold(html_plain);
let b = fold(pasted);
a == b
}
fn fold(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn google_docs_normal_weight_wrapper_is_not_bold() {
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "plain words");
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
let plain = html_to_body("<b>strong</b>").unwrap();
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
body.paragraphs[0].runs = vec![
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
Run::new(" "),
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
];
let html = body_to_html(&body);
assert!(html.contains("<b>Hi</b>"), "{html}");
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
let back = html_to_body(&html).unwrap();
assert_eq!(back.text(), "Hi there");
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
let last = back.paragraphs[0].runs.last().unwrap();
assert_eq!(last.props.italic, Some(true));
assert!(last.props.underline.is_some());
}
#[test]
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
let text = |html: &str| html_to_body(html).unwrap().text();
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
assert_eq!(text("<p>A &amp; B &#38; C &#x26; D</p>"), "A & B & C & D");
// Verify many bare ampersands do not cause quadratic scan freeze
let hostile = format!("<p>{}</p>", "&".repeat(2000));
assert_eq!(text(&hostile), "&".repeat(2000));
}
#[test]
fn pages_style_and_escapes() {
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A &amp; B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "A & B\nC");
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
assert!(same_words(&body.text(), "A & B\nC"));
assert!(!same_words(&body.text(), "other"));
}
#[test]
fn a_newline_in_html_text_is_a_space() {
assert_eq!(decode_text("Hello\nworld"), "Hello world");
let body = html_to_body("<p>Hello\r\n world</p>").unwrap();
assert_eq!(body.text(), "Hello world");
let body = html_to_body("<p>\n<b>Hello</b>\n<i>world</i>\n</p>\n<p>next</p>").unwrap();
assert_eq!(body.text(), "Hello world\nnext");
assert!(same_words(&body.text(), "Hello world\nnext"));
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true));
}
#[test]
fn a_huge_fragment_is_refused() {
let html = "x".repeat(MAX_HTML + 1);
assert!(html_to_body(&html).is_none());
}
}
+64
View File
@@ -0,0 +1,64 @@
//! The ruler's data and tab-stop editing.
use serde_json::json;
use crate::Session;
fn editing() -> (Session, u64) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.insert", &json!({"text": "a\tb"})).unwrap();
(s, id)
}
#[test]
fn ruler_reports_the_text_area_and_indents() {
let (mut s, _) = editing();
let r = s.execute("format.ruler", &json!({})).unwrap();
assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}");
assert_eq!(r["rotated"], false);
assert_eq!(r["tabs"].as_array().unwrap().len(), 0);
s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap();
let r = s.execute("format.ruler", &json!({})).unwrap();
assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0)));
s.execute("text.exit", &json!({})).unwrap();
assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text");
}
#[test]
fn tabs_add_move_remove_clear_and_undo() {
let (mut s, _) = editing();
let tabs = |s: &mut Session| -> Vec<(f64, String)> {
s.execute("format.ruler", &json!({})).unwrap()["tabs"]
.as_array()
.unwrap()
.iter()
.map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string()))
.collect()
};
s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap();
s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap();
assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted");
s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap();
assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced");
s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap();
s.execute("format.tabs", &json!({"remove": 72.2})).unwrap();
assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tabs(&mut s).len(), 2);
s.execute("format.tabs", &json!({"clear": true})).unwrap();
assert!(tabs(&mut s).is_empty());
s.ui_requests.clear();
s.execute("format.tabs", &json!({})).unwrap();
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog");
assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash");
for i in 0..100 {
s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap();
}
assert!(tabs(&mut s).len() <= 32);
s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap();
assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped");
assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0));
}
+4 -1
View File
@@ -28,7 +28,10 @@ pub fn specs() -> Vec<CommandSpec> {
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
/// object from the list shows it again.
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => id_param(p, "id", "selection.select")?.map(|i| vec![i]).unwrap_or_default(),
};
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
let st = s.doc()?;
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
+1 -1
View File
@@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
// A: x 20..120, y 50..130.
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
File diff suppressed because it is too large Load Diff
+150 -36
View File
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
cmd!(
"slide.notes",
"Notes",
["View"],
None,
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
has_slide,
notes
),
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
@@ -47,7 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all),
cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move),
cmd!("slide.fromOutline", "Slides from Outline…", ["Insert", "Slides From"], None, "{text}", has_doc, from_outline),
cmd!(
"slide.fromOutline",
"Slides from Outline…",
["Insert", "Slides From"],
None,
"{text, markdown?: bool (headings are slides, list items bullets)}",
has_doc,
from_outline
),
]
}
@@ -99,16 +115,21 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
}
}
};
let mut slide = defaults::new_slide(doc, layout);
let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("slide.new", NO_ID_SPACE))?;
if let Some(t) = str_param(p, "title")
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title()))
{
sh.text = Some(TextBody::from_text(t));
}
if let Some(b) = str_param(p, "body")
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
&& let Some(sh) = body_placeholder(&mut slide.shapes)
{
sh.text = Some(TextBody::from_text(b));
let mut body = TextBody::from_text(b);
// Leading tabs set the outline level, as in text.set.
for para in &mut body.paragraphs {
super::text::apply_leading_tabs(para);
}
sh.text = Some(body);
}
let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len());
let id = slide.id;
@@ -137,9 +158,28 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
/// shape order, so a layout with a single body placeholder behaves as before.
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
use deckcraft_model::PhType;
let rank = |sh: &deckcraft_model::Shape| {
let ph = sh.ph.as_ref()?;
if ph.kind.is_title() {
return None;
}
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
Some((ph.idx != 1, small, !text))
};
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
shapes.get_mut(best)
}
fn index_of(s: &Session, p: &Value) -> Result<usize> {
let st = s.doc()?;
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide", format!("no slide {i}")));
}
@@ -147,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let indices: Vec<usize> = match usize_param(p, "index") {
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
@@ -165,25 +205,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
for (k, i) in indices.iter().enumerate() {
let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue };
let mut copy = src;
copy.id = SlideId(doc.alloc_id());
super::edit::reid(doc, &mut copy.shapes);
// Animations follow the new shape ids by position.
let old_ids: Vec<_> = doc
.slides
.get(*i)
.map(|x| {
let mut v = vec![];
deckcraft_model::walk(&x.shapes, &mut |sh, _| v.push(sh.id));
v
})
.unwrap_or_default();
let mut new_shape_ids = vec![];
deckcraft_model::walk(&copy.shapes, &mut |sh, _| new_shape_ids.push(sh.id));
for a in &mut copy.animations {
if let Some(pos) = old_ids.iter().position(|x| *x == a.shape) {
a.shape = new_shape_ids.get(pos).copied().unwrap_or(a.shape);
}
}
copy.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.duplicate", NO_ID_SPACE))?);
// Animations follow the new shape ids, triggers included.
let map = super::edit::reid("slide.duplicate", doc, &mut copy.shapes)?;
super::edit::remap_animations(&mut copy.animations, &map);
let section = doc.section_of(src_id(doc, *i));
let at = (last + 1 + k).min(doc.slides.len());
new_ids.push(copy.id);
@@ -193,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(sec) = doc.sections.get_mut(si)
{
sec.slides.push(nid);
// Keep the section list in document order so the copy sits next to its source.
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
@@ -212,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
} else if let Some(i) = usize_param(p, "index") {
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
} else if !st.selection.slides.is_empty() {
st.selection.slides.clone()
@@ -238,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
s.edit(|doc, sel| {
if from >= doc.slides.len() {
@@ -283,9 +311,16 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
fn go(s: &mut Session, p: &Value) -> Result<Value> {
let i = match (usize_param(p, "index"), p.get("id").and_then(Value::as_u64)) {
(Some(i), _) => i,
(None, Some(id)) => s.doc()?.doc.slide_index(SlideId(u32::try_from(id).unwrap_or(0))).ok_or_else(|| bad("slide.go", "no such slide"))?,
(None, Some(id)) => s
.doc()?
.doc
.slide_index(SlideId(u32::try_from(id).map_err(|_| bad("slide.go", "no such slide"))?))
.ok_or_else(|| bad("slide.go", "no such slide"))?,
_ => return Err(bad("slide.go", "missing `index`")),
};
if i >= s.doc()?.doc.slides.len() {
return Err(bad("slide.go", format!("no slide {i}")));
}
go_to(s, i)
}
fn next(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -364,7 +399,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?;
let mut fresh = vec![];
for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) {
let id = deckcraft_model::ShapeId(doc.alloc_id());
let id = deckcraft_model::ShapeId(doc.alloc_id().ok_or_else(|| bad("slide.layout", NO_ID_SPACE))?);
fresh.push((id, ls.clone()));
}
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?;
@@ -463,10 +498,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
let i = index_of(s, p)?;
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
// changes, so bold, levels and bullets around it survive.
let body = match p.get("body") {
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
None => None,
};
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|doc, _| {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).notes = TextBody::from_text(&text);
let notes = &mut Arc::make_mut(sl).notes;
match body {
Some(b) => *notes = b,
None => deckcraft_model::edit::replace_text(notes, &text),
}
}
Ok(())
})?;
@@ -486,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
}
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
s.edit(|doc, _| {
let at = at.min(doc.slides.len().saturating_sub(1));
@@ -494,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
if doc.sections.is_empty() {
// The slides before `at` go into a "Default Section".
if at > 0 {
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
}
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
} else {
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
let si = doc.section_of(start).unwrap_or(0);
@@ -572,15 +617,84 @@ fn section_move(s: &mut Session, p: &Value) -> Result<Value> {
fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let markdown = bool_param(p, "markdown").unwrap_or(false);
s.edit(|doc, sel| {
let before = doc.slides.len();
let n = deckcraft_format::outline_to_slides(doc, &text);
// outline_to_slides appends; move them after the current slide.
let n = if markdown { deckcraft_format::markdown_to_slides(doc, &text) } else { deckcraft_format::outline_to_slides(doc, &text) };
// both append; move them after the current slide.
let at = (sel.slide + 1).min(before);
let new: Vec<_> = doc.slides.drain(before..).collect();
for (k, sl) in new.into_iter().enumerate() {
doc.slides.insert(at + k, sl);
}
// Join the section of the slide before, as slide.new does.
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let section = prev.and_then(|p| doc.section_of(p));
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
let pos = pos.min(sec.slides.len());
sec.slides.splice(pos..pos, ids);
} else if let Some(first) = doc.sections.first_mut() {
first.slides.splice(0..0, ids);
}
Ok(json!({"slides": n}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("slide.delete", json!({"index": bad})),
("slide.duplicate", json!({"index": bad})),
("slide.move", json!({"from": bad, "to": 0})),
("section.add", json!({"at": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
}
}
}
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
s.execute("section.add", &json!({"at": 0})).expect("section");
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
let st = s.doc().expect("doc");
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
let sec = &st.doc.sections.first().expect("section").slides;
assert_eq!(all.len(), 4);
assert_eq!(sec, &all, "section order must match slide order");
assert_eq!(sec.first(), Some(&orig));
}
#[test]
fn new_slide_body_leading_tabs_become_levels() {
let mut s = Session::with_new();
let text = "Level one\n\tLevel two\n\t\tLevel three\nLevel one again";
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Indent", "body": text})).expect("new slide");
let st = s.doc().expect("doc");
let slide = st.doc.slides.get(st.selection.slide).expect("slide");
let body = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| !k.is_title())).expect("body");
let paras = &body.text.as_ref().expect("text").paragraphs;
let got: Vec<(u8, String)> = paras.iter().map(|p| (p.level, p.text())).collect();
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
}
}
File diff suppressed because it is too large Load Diff

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