Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor fee716092c Validate staged FreeBSD binaries before packaging
Check that deckcraft and deckcraft-cli are 64-bit little-endian FreeBSD ELF files
for the selected arch before the archive is created, so placeholder, wrong-arch
or non-FreeBSD binaries fail instead of being packaged. Applies to --skip-build
and full builds.
2026-10-28 15:13:01 -04:00
Dylan M. Taylor edf07dfc14 Resolve host-tuple and reject unsupported targets in FreeBSD packaging
Resolve CARGO_BUILD_TARGET=host-tuple to the real host triple before it is used in
the target directory path. Accept only x86_64-unknown-freebsd and
aarch64-unknown-freebsd when it is set, and fail with an error for anything else
instead of mapping unknown targets to x86_64.
2026-10-28 14:51:14 -04:00
Dylan M. Taylor 494daaa311 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds
package.sh took the arch from uname -m and read binaries from target/release,
so a cross-built aarch64 target would be packaged as the host arch from the
wrong directory. Pass --target, read target/<triple>/release, and name the
archive from the target triple when CARGO_BUILD_TARGET is set. Skip the
--version smoke run for cross builds, since the binary cannot run on the host.
2026-10-28 14:41:03 -04:00
Dylan M. Taylor c2f3397483 Grant network access so update checks can reach GitHub (#1502) 2026-10-28 13:07:13 -04:00
Brandon ThomasandClaude Opus 5.5 29f0f7772c Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:01 -04:00
Brandon ThomasandClaude Opus 5.5 d97a596780 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:51:58 -04:00
Dylan M. Taylor 6d09d3770f Merge pull request #1444 from dylanmtaylor/sonar/enforce-clippy-standards
Enforce Rust coding standards via Clippy
2026-10-28 06:18:43 -04:00
Dylan M. Taylor 6ddd2fb869 Enforce workspace Clippy standards and document lint policy 2026-10-28 06:02:28 +00:00
Dylan M. Taylor db245a7029 Refactor deckcraft-cli for strict Clippy checks 2026-10-28 06:02:28 +00:00
Dylan M. Taylor 8c6c72e39f Refactor ui-egui for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 1a203c4a73 Refactor engine for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 7f1ebc2a5c Refactor render for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 8e4d0e6b4c Refactor pptx for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 3f853db3f6 Refactor av1 for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor d9749fbcc5 Refactor h264 for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 0e7cd01381 Refactor hevc for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 9c52950536 Refactor vp9 for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor c58f19f1b5 Refactor opus for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 4de3a4dc8b Refactor text for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor f441ea93a2 Refactor metafile for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor 6a59eb8cef Refactor model for strict Clippy checks 2026-10-28 06:02:27 +00:00
Dylan M. Taylor e03ff82504 Refactor geom for strict Clippy checks 2026-10-28 06:02:27 +00:00
aiongg 55e5ee56b4 Merge pull request #1434 from Jeremy8776/feat/install-scope-policy
Windows installation policy: DeckCraft
2026-10-27 17:09:15 -04:00
aiongg f2f4dfabc0 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:55 +00:00
Jeremy 8cb3365596 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:08:41 +00:00
aiongg 88c2ceff5f Merge pull request #1461 from Silvertree2010/perf/export-speed
Speed up PDF export by reusing equal slide backdrops
2026-10-27 13:42:02 -04:00
NetterSilverandClaude Opus 5.5 2f4a6d906a Speed up PDF export by reusing equal slide backdrops
PDF export rasterises every slide as a backdrop and a content layer and
hands both to the writer. Profiling (gdb stack sampling) showed most of
the main thread outside rendering going to per-pixel work on those
rasters: compositing the backdrop onto white (not vectorised), the
writer hashing every backdrop to deduplicate it, and the straight-alpha
conversion scanning every content pixel.

- Composite the backdrop with saturating adds and skip 16-pixel blocks
  that are already opaque; skip blocks with no partly transparent pixel
  in the straight-alpha conversion. Same formulas, same bytes.
- Keep the last opaque backdrops (up to 32 MiB of pixels) and hand the
  writer the same image again when a slide renders an identical
  backdrop. The writer stored equal backdrops once already; now it also
  skips hashing and compressing the copies. Output is byte-identical.

deckcraft-cli bench, export .pdf step, 15 interleaved runs each on a
16-thread Ryzen (A/A noise +0.9 %):
- sample x4 (32 slides): median 531 -> 322 ms (-39 %), min 529 -> 317 ms
- same deck, every slide a different background: 557 -> 441 ms (-21 %)
- sample (8 slides): 149 -> 103 ms (-31 %)
Exported PDFs are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:39:31 +01:00
NetterSilverandClaude Opus 5.5 53e183495b Time PDF export over all bench runs
`deckcraft-cli bench` capped the "export .pdf" step at three runs while
its help promises the median of N runs for every step. Use --runs for
it too, so a PDF export benchmark can take enough samples to beat noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:43:29 +01:00
Jeremy 8445d98e24 fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:16 +00:00
aiongg 2c9edcc394 refactor(ui): use craft-ui's ComboBox for every dropdown (#1457)
* ui: use craft_ui::ComboBox so dropdown menus grow back (egui#5225); repin craft-libs

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)

* chore(xtask): pdf-deps expects craft-libs 92d392cf
2026-10-27 10:26:51 -04:00
Jeremy 428c8809ea fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:00:56 +00:00
Jeremy abee4d7522 fix(installer): address scope and ARM64 review feedback 2026-10-27 12:00:56 +00:00
Jeremy 4d9b325d35 feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:00:56 +00:00
aiongg ee875abffa Merge pull request #1433 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:30:28 -04:00
skylar taylor-barrick 020e53e7a0 Merge pull request #1429 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:27 -06:00
aiongg 12001d14e7 refactor(engine): split cmd/shape.rs into cmd/shape/{size,appearance,picture,freeform}.rs 2026-10-26 22:10:40 +00:00
aiongg bbf7a8b8c7 docs: update source paths after the module layout moves 2026-10-26 22:08:44 +00:00
aiongg 96f0ac204e test(engine): read past #[cfg(test)] imports in the file-system scan 2026-10-26 22:08:44 +00:00
aiongg 6b57773373 refactor(geom): split preset.rs into preset/{catalog,builder,build,shapes}.rs 2026-10-26 22:08:44 +00:00
aiongg 838d51ed59 refactor(text): split lib.rs into {run,style,autonum,shape,symbol,breaking,layout}.rs 2026-10-26 22:08:44 +00:00
aiongg 5b02b1744c refactor(render): split lib.rs into {raster,opts,slide,renderer,geometry,stroke,text_draw}.rs 2026-10-26 22:08:44 +00:00
aiongg 8b456dce41 refactor(engine): split tools.rs into tools/{hit,pointer,drag,click,geometry}.rs 2026-10-26 22:08:44 +00:00
aiongg 15bf821535 refactor(engine): split cmd/text.rs into cmd/text/{ime,layout,editing,autocorrect,caret,formatting}.rs 2026-10-26 22:08:44 +00:00
aiongg d7c06b53eb refactor(engine): split cmd/smartart.rs into cmd/smartart/{nodes,paint,layout,diagrams,edit}.rs 2026-10-26 22:08:44 +00:00
aiongg 97b98767f1 refactor(engine): split cmd/insert.rs into cmd/insert/{picture,album,chart,text_fields,links}.rs 2026-10-26 22:08:44 +00:00
aiongg d5180fbb70 refactor(engine): split cmd/format.rs into cmd/format/{font,text_effects,paragraph,painter,ruler}.rs 2026-10-26 22:08:44 +00:00
aiongg 9c222be711 refactor(engine): split cmd/design.rs into cmd/design/{colors,slide_background,slide_setup,master_view}.rs 2026-10-26 22:08:44 +00:00
aiongg 28371e27d7 refactor(ui-egui): split backstage.rs into backstage/{files,info,print}.rs 2026-10-26 22:08:44 +00:00
aiongg 4bdd5b5bee refactor(ui-egui): split icons.rs into icons/{geometry,draw}.rs 2026-10-26 22:08:44 +00:00
aiongg 99688fe45a refactor(ui-egui): split show.rs into show/{render,keys,ui,media,presenter,zoom}.rs 2026-10-26 22:08:44 +00:00
aiongg ada66524a2 refactor(ui-egui): split panels.rs into one file per pane under panels/ 2026-10-26 22:08:44 +00:00
aiongg e84ba9def1 refactor(ui-egui): split ribbon.rs into one file per tab under ribbon/ 2026-10-26 22:08:44 +00:00
aiongg 9545ff4d61 refactor(ui-egui): move the zoom, trim, about and alt-text test modules to their dialog files 2026-10-26 22:08:44 +00:00
aiongg c8a6363a0f refactor(ui-egui): split dialogs.rs into one file per dialog under dialogs/ 2026-10-26 22:08:44 +00:00
aiongg 462310faed refactor(ui-egui): split canvas.rs into canvas/{input,scroll,ime,media,rulers,guides,overlays,context_menu}.rs 2026-10-26 22:08:44 +00:00
aiongg d428b4804a refactor(ui-egui): move the backstage test files beside backstage.rs 2026-10-26 22:08:44 +00:00
aiongg 9ab4ed9581 refactor(ui-egui): move the sorter test files beside sorter.rs 2026-10-26 22:08:44 +00:00
aiongg a094f37ed9 refactor(ui-egui): move the ribbon test files beside ribbon.rs 2026-10-26 22:08:44 +00:00
aiongg 742e660afd refactor(ui-egui): move the dialogs test files beside dialogs.rs 2026-10-26 22:08:44 +00:00
aiongg 8338e21ecf refactor(ui-egui): move the canvas test files beside canvas.rs 2026-10-26 22:08:44 +00:00
aiongg 7ab59a0cd3 refactor(ui-egui): move qat to ribbon/qat.rs 2026-10-26 22:08:44 +00:00
aiongg a730e66757 refactor(ui-egui): move updates_ui to dialogs/updates_ui.rs 2026-10-26 22:08:44 +00:00
aiongg ac3c3b7b2a refactor(ui-egui): move credits to dialogs/credits.rs 2026-10-26 22:08:44 +00:00
aiongg 1368d06eeb refactor(ui-egui): move squiggles to canvas/squiggles.rs 2026-10-26 22:08:44 +00:00
aiongg af61c1cf3a refactor(ui-egui): move preview to canvas/preview.rs 2026-10-26 22:08:44 +00:00
aiongg 099dfa7670 refactor(ui-egui): move the panels test files beside panels.rs 2026-10-26 22:08:44 +00:00
aiongg d1022cdaa4 refactor(ui-egui): move animtimeline to panels/animtimeline.rs 2026-10-26 22:08:44 +00:00
aiongg dcd06db2d3 refactor(ui-egui): move fillui to panels/fillui.rs 2026-10-26 22:08:44 +00:00
aiongg 82fb8e7680 refactor(ui-egui): move notes to panels/notes.rs 2026-10-26 22:08:44 +00:00
aiongg 59e4b272d8 refactor(ui-egui): move selpane to panels/selpane.rs 2026-10-26 22:08:44 +00:00
aiongg 62aa7aa437 refactor(ui-egui): move thumbs to panels/thumbs.rs 2026-10-26 22:08:44 +00:00
aiongg af9b8c1adb refactor(ui-egui): rename panes to panels 2026-10-26 22:08:44 +00:00
aiongg d6026de7c2 refactor(ui-egui): move SlideApp method groups to app/{commands,files,ime,input,keys,playback,clipboard,frame,control}.rs 2026-10-26 22:08:44 +00:00
aiongg d9199598e1 refactor(ui-egui): move SlideApp methods to app.rs and lib.rs types to services.rs, state.rs, clock.rs, ime_hooks.rs 2026-10-26 22:08:44 +00:00
aiongg a2194407a1 refactor(media): move player tests to player/tests.rs 2026-10-26 22:08:44 +00:00
aiongg b440c2ff40 refactor(geom): move preset tests to preset/tests.rs 2026-10-26 22:08:44 +00:00
aiongg 65a3ddc8f0 refactor(format): move lib.rs tests to tests.rs 2026-10-26 22:08:44 +00:00
aiongg 00eb5caad6 refactor(fonts): move lib.rs tests to tests.rs 2026-10-26 22:08:44 +00:00
aiongg 50feadc965 refactor(deckcraft): move control_server tests to control_server/tests.rs 2026-10-26 22:08:44 +00:00
aiongg e9a7413c87 refactor(engine): move insert icon_and_picture_tests to cmd/insert/icon_and_picture_tests.rs 2026-10-26 22:08:44 +00:00
aiongg b48467943b refactor(engine): move design browse_theme_tests to cmd/design/browse_theme_tests.rs 2026-10-26 22:08:44 +00:00
aiongg 0f4665ebe2 refactor(engine): move design tests to cmd/design/tests.rs 2026-10-26 22:08:44 +00:00
aiongg 8528674c57 refactor(ui-egui): move dialogs tests to dialogs/tests.rs 2026-10-26 22:08:44 +00:00
aiongg 90a3ffe766 refactor(ui-egui): move canvas tests to canvas/tests.rs 2026-10-26 22:08:44 +00:00
aiongg 03025647d6 refactor(ui-egui): move show tests to show/tests.rs 2026-10-26 22:08:44 +00:00
aiongg 583f2dfb1b style(ui-egui): rustfmt tests.rs after the move 2026-10-26 22:08:43 +00:00
aiongg 33a0df30d6 refactor(ui-egui): move lib.rs tests to tests.rs 2026-10-26 22:08:43 +00:00
aiongg 36f454b60f refactor(ui-egui): move capture tests to capture/tests.rs 2026-10-26 22:08:43 +00:00
aiongg 9ce54ad73f refactor(opus): move silk/mod.rs to silk.rs 2026-10-26 22:08:43 +00:00
aiongg 2fb85dfa2f refactor(opus): move celt/mod.rs to celt.rs 2026-10-26 22:08:43 +00:00
aiongg 1a2f8c0b0f refactor(pptx): move write/mod.rs to write.rs 2026-10-26 22:08:43 +00:00
aiongg b7a343d9cf refactor(pptx): move read/mod.rs to read.rs 2026-10-26 22:08:43 +00:00
aiongg f987fe63a7 refactor(engine): move cmd/mod.rs to cmd.rs 2026-10-26 22:08:43 +00:00
skylar 899cd0e0dc chore: merge current main for nextest update 2026-10-26 14:38:31 -06:00
skylar d741144240 ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:05 -06:00
Dylan M. Taylor 27009cd941 refactor: reduce cognitive complexity in five more functions (S3776) 2026-10-26 16:01:57 -04:00
Dylan M. Taylor 4bb73d5d34 refactor: reduce cognitive complexity in five functions (S3776) 2026-10-26 16:01:53 -04:00
Dylan M. Taylor aaf6c1c38a refactor(show): split the key handler into grid, navigation and tool keys (S3776) (#1402) 2026-10-25 21:13:50 -04:00
Dylan M. Taylor 16fe4ebc65 refactor(hevc): split derive_merge into spatial, temporal, combined and zero stages (S3776) (#1401) 2026-10-25 20:55:39 -04:00
Dylan M. Taylor 430d0a91c0 refactor(hevc): split prediction_unit into merge, AMVP and store helpers (S3776) (#1400) 2026-10-25 20:55:35 -04:00
Dylan M. Taylor c93e0a0bac refactor(rtf): split the outline reader into per-construct methods (S3776) (#1399) 2026-10-25 20:55:30 -04:00
Dylan M. Taylor aa3a1baa01 refactor(av1): reduce complexity in cdef and loop restoration (S3776) (#1278) 2026-10-25 20:26:38 -04:00
Dylan M. Taylor edfee40392 refactor(av1): split inverse transform into stages and passes (S3776) (#1277) 2026-10-25 20:26:33 -04:00
Dylan M. Taylor 580b577cf2 fix(h264): use Option::as_ref instead of closure (S1612) (#1268) 2026-10-25 20:26:28 -04:00
Sofia Alves FigueredoandClaude Sonnet 5.5 230b6485d9 fix(windows): keep the shortcut icon after an update (#1114) (#1391)
* fix(windows): keep the shortcut icon after an update (#1114)

The MSI's Start menu shortcut took its icon from the WiX <Icon>, which Windows
Installer caches under %APPDATA%\Microsoft\Installer\{ProductCode}\. Every
update is a major upgrade with a new ProductCode, so the old folder is deleted.
A taskbar pin is a copy of the shortcut that the update never touches, so it
kept pointing at the deleted file and lost its icon.

Drop the Icon from the shortcut so it shows the icon embedded in deckcraft.exe,
whose path survives updates. The <Icon> stays for ARPPRODUCTICON. A test keeps
shortcuts from naming an MSI icon again.

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

* contributors: credit Sofia Alves Figueredo by real name

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

* fix(windows): make the Start menu shortcut non-advertised (#1114)

Addresses the review comment: without an MSI Icon, a non-advertised shortcut
targets the installed exe directly and takes its embedded icon.

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

* fix(windows): package.ps1 rejects a deckcraft.exe with no embedded icon (#1114)

The shortcuts now take the exe's own icon, and -SkipBuild packages an existing
exe that may come from a build without DECKCRAFT_REQUIRE_WINRES=1. Count its
icons with ExtractIconEx and stop before WiX when there are none.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:23:43 -04:00
Dylan M. Taylor 3418dec9e4 refactor(h264): Sonar S3776 dpb helpers (#1275)
* refactor(h264): split dpb functions (S3776)

* fix(h264): restore the MMCO 6 long-term index range check
2026-10-25 19:36:54 -04:00
Dylan M. Taylor a78d9a9a19 refactor(av1): split film grain synthesis into stages (S3776) (#1276) 2026-10-25 19:23:04 -04:00
Dylan M. Taylor 560f80abe2 refactor(h264): split derive_inter_motion and residual_cavlc (S3776) (#1272) 2026-10-25 19:22:58 -04:00
Dylan M. Taylor 2aa0310e27 refactor(av1): split OBU parsing in decoder (S3776) (#1279) 2026-10-25 19:22:35 -04:00
Dylan M. Taylor b6fd616977 refactor(h264,hevc): reduce parameter counts (S107) (#1271)
Group geometry/size/weight parameters into tuples and a PbGeom struct.
2026-10-25 19:21:54 -04:00
Dylan M. Taylor 9d23ff97b2 Merge pull request #1394 from storytold/chore/deps-safe-refresh
chore(deps): refresh serde_json, arboard, winresource, i_overlay
2026-10-25 19:00:58 -04:00
skylar 1bee1857d6 chore(deps): bump serde_json, arboard, winresource, i_overlay 2026-10-25 16:36:20 -06:00
Dylan M. Taylor 13ed8889da refactor(av1): use method path instead of closure (S1612) (#1269) 2026-10-25 18:18:36 -04:00
Dylan M. Taylor dbc5e164dd build: pin craft-update to craft-libs main (495e597) (#1388)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

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

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

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

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

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

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

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

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

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

Ref: storytold/craft-libs#319

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

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

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

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

* fix: geom extra imports

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

* fix: drop unused imports

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

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

---------

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

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

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

* E21: restore Latin coverage and enforce required font inputs

* Fix migration review findings in font validation and UI controls

* M-egui037: document UI lifecycle and regression contracts
2026-10-25 10:48:52 -04:00
FaabyyandClaude 52d0fcd6c7 Reuse Slides: insert into the presentation and after the slide the picker was opened for (#1103)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:23 +00:00
FaabyyandClaude 121441a079 Slides: the range anchor is held by slide id, so moving, inserting or deleting slides can't shift it (#1105)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:18 +00:00
Brandon ThomasandClaude Opus 5.5 9ec3926793 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:20 -04:00
Brandon ThomasandClaude Opus 5.5 79bf9a688c macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:13:55 -04:00
Brandon ThomasandClaude Opus 5.5 1330ce4b86 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:04:15 -04:00
FaabyyandClaude abdf5f337b Slides: a second Shift-click extends from the same anchor; the range clears cells and returns to the slides; a bad from is an error (#1105)
Review on #1107: read `from` with opt_usize_param, clear `sel.cells` and set
`target = Slides` as `slide.go` did, keep the anchor of the first Shift-click.
A kittest drives the Shift and Ctrl+Shift clicks in the slide sorter.

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:35:40 +01:00
aiongg a0eeb341d2 Colour names, media ids after a paste, and pressing a table outside its cells (#1344 follow-ups) (#1348)
* Colors: unknown preset and system colour names are rejected, and the writer saves black or windowText for them

* Gaps: copying text and cell ranges, list AutoFormat's option and Backspace, and a timing-sensitive control-server test

* Paste and Compare: media kept under a foreign id raises the next id, so a later picture gets an id of its own

* Tables: with a cell range selected, a press on the table outside its cells selects and moves the table

* Tables: a click on a selected table's grab band keeps the table selected instead of entering the nearest cell
2026-10-25 01:09:38 -04:00
aiongg 80f13011d5 Merge pull request #1344 from aiongg/dcc-batch1
Merge 14 open PRs (tested batch)
2026-10-24 23:48:59 -04:00
aiongg 399bc5a79f Integration: editing text in a table cell ends a selected cell range 2026-10-25 03:32:41 +00:00
aiongg 3821a8f7f7 Integration: the pattern gallery offers the 48 gallery presets in gallery order; the six legacy ones still draw 2026-10-25 02:45:46 +00:00
aiongg 08cc9ada67 Integration: Esc during a handle drag also takes back the point type a modifier chose 2026-10-25 02:42:04 +00:00
aiongg 7d21b25330 Integration: one Edit points row in the UI parity table, and the progress log in date order 2026-10-25 02:33:30 +00:00
aiongg cc4629c2cd Merge #1338: Format: Fill shows Color and Transparency only for fills they change 2026-10-25 02:22:59 +00:00
aiongg 4668e0b45a Merge #1335: Format: Pattern fill shows a gallery of every preset with Foreground and Background colors 2026-10-25 02:22:58 +00:00
aiongg ca919b0c9b Merge #1332: Edit Points: right-click a segment for Add Point, Delete Segment and Open/Close Path
# Conflicts:
#	crates/engine/src/tools.rs
2026-10-25 02:22:57 +00:00
aiongg 85db5caed4 Merge #1325: Edit Points: Esc before releasing the mouse button cancels a point or handle drag 2026-10-25 02:22:39 +00:00
aiongg 2b9b83ef83 Merge #1308: Edit Points: Shift, Ctrl or Alt while dragging a handle makes the point smooth, straight or corner 2026-10-25 02:22:38 +00:00
aiongg aaaee73c6f Merge #1301: Text: a Columns dialog sets the number of columns and the spacing between them 2026-10-25 02:22:38 +00:00
aiongg d482443dd8 Merge #1289: Paste: a table, bullet or slide from another deck keeps its own pictures and sounds 2026-10-25 02:22:37 +00:00
aiongg d863fac1c6 Merge #1282: Tables: dragging across cells selects a range of cells 2026-10-25 02:22:37 +00:00
aiongg b23e6f2c8f Merge #1258: Text: AutoFormat starts bulleted and numbered lists as you type 2026-10-25 02:21:58 +00:00
aiongg da8db49aef Merge #1327: Docs: Play From and the .potx/.ppsx content types are no longer listed as missing 2026-10-25 02:21:57 +00:00
aiongg 149ea6b8da Merge #1307: Status bar: screen readers can name its controls and read the slide counter and status message 2026-10-25 02:21:56 +00:00
aiongg b913a0c509 Merge #1300: Tests: the Notes-button test clicks where the button is laid out, so it passes without Inter 2026-10-25 02:21:56 +00:00
aiongg 2fd61c7c0c Merge #1298: Control server: a peer refused as busy still reads why, and two tests no longer race 2026-10-25 02:21:55 +00:00
aiongg e167e08e41 Merge #1292: Tests: UI tests that render with wgpu take turns, so the Windows test run no longer crashes 2026-10-25 02:21:55 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 6a1c7354c1 security(anim): bound custom-show and timeline lookups, share motion paths across paragraph effects (#1341)
* security(anim): index slides and shapes once, share paragraph targets and motion paths

Custom-show resolution, timeline target lookup and by-paragraph builds scaled with the product
of two document-controlled counts, and paragraph expansion deep-copied the parsed motion path
into every effect.

- show_order / custom_indices resolve slide ids through one id->index map.
- Timeline::with indexes shapes once (same first-wins order as Slide::shape) for animation
  targets and base colours, and records each animated shape's colour with a set.
- Paragraph targets are worked out once per shape and shared across its animations.
- Effects share their EffectSpec (and so the motion path) by Rc instead of cloning it.

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

* fix(anim): use Arc for EffectSpec to satisfy Sync and early return on empty animations

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:19:37 -04:00
dotCooCoo 261e74e0c8 Format: pattern edits keep preset and system colors, and the gallery shows focus
A pattern fill imported with a preset (prstClr) or system (sysClr) color
turned that color black on any change in the gallery or either color
menu, because the color parameter fell back to #000000 for those bases.
Color parameters now carry them as {"preset": name} and
{"system": name, "last": "#RRGGBB"}, with their transforms, and the
engine reads both forms.

A pattern swatch with keyboard focus now gets the same outline as a
hovered or selected one.
2026-10-24 15:21:01 -07:00
dotCooCoo be56b0a1ac Format: Fill shows Color and Transparency only for fills they change
Format Shape's Fill section showed the solid-fill Color button with a
gradient, a picture or no fill, and picking a color there replaced the
fill with a solid one. With a gradient it also showed a Transparency
slider that did not change the fill.

The Color button now shows only when the Fill options show Solid fill
or Automatic, and the Transparency slider shows for a solid, automatic
or picture fill. A fill that follows the group or the slide background
shows as Automatic and has no Transparency slider. With a picture fill
the slider starts at the picture's transparency instead of 0%.
2026-10-24 14:42:27 -07:00
Marco Arnulfo ab43e4b8f2 Units: Slide Size keeps each field's exact size in points, so a preset or Portrait gives the exact slide size (#1326) 2026-10-24 23:18:01 +02:00
dotCooCoo 2ddd2abe67 Format: Pattern fill shows a gallery of every preset with Foreground and Background colors
Format Shape's Pattern fill set 25 % in accent 1 on background 1 and
offered no way to change it, and Format Background listed 10 of the 54
presets by their XML names. Both panes now show the same editor: a
gallery of the 54 DrawingML presets drawn in the fill's own colors,
each named in words, and Foreground and Background color menus.

With a pattern fill, Format Shape no longer shows the solid-fill Color
button, which replaced the pattern with a solid fill, or the
Transparency slider, which did not change a pattern. The renderer
exports the preset list and tiles for the gallery to draw.
2026-10-24 14:13:22 -07:00
Marco Arnulfo a7ba879a3c Pasted shapes are renamed only when the name is already taken on the slide; new shapes skip names in use (fixes #1290) 2026-10-24 23:10:35 +02:00
Marco Arnulfo d384bbc496 Units: Preferences chooses inches or centimetres for the Format pane, the ribbon's size boxes, the Slide Size dialog and the ruler (#1326) 2026-10-24 23:02:45 +02:00
Marco Arnulfo 98ce1b35be Pasted shapes get the next automatic name instead of keeping the original's (fixes #1290) 2026-10-24 22:52:52 +02:00
dotCooCoo ba59df768b Edit Points: a panic after a change of document leaves no point type
The guard put back the remembered point type after a panic even when the
command had made another document active, so the type of a shape in the
first document applied to a shape with the same id in the second. The
guard now puts it back only when the document from before is still active,
and forgets it otherwise.
2026-10-24 13:40:54 -07:00
dotCooCoo 0cffe2ca08 Edit Points: a panic or another document leaves no stale point type
A command that panicked after changing the point type Edit Points remembers
left the new type in place, since the guard put back only the document and
selection. The guard now puts the remembered type back too. Shape ids repeat
across documents, so a change of active document forgets the remembered
type.
2026-10-24 13:19:40 -07:00
dotCooCoo 92ce587cd6 Edit Points: right-click a segment for Add Point, Delete Segment and Open/Close Path
The segment menu in Edit Points offered only Straight Segment and Curved
Segment. It now also has Add Point, which adds a point where the right
click was, Delete Segment, which removes the segment from a closed outline
so that the outline opens there (grayed out on an open outline), and Open
Path, which opens the outline at the segment's start point (Close Path on
an open outline).
`shape.deleteSegment` does the deletion for the CLI and MCP.

The place on a segment that a click picks was found only in steps of 1/32
of the segment, so Add Point and Ctrl-click could add the point as far
as 1/64 of a straight segment's length from the pointer. It is now narrowed
down after that first pass.

Adding or deleting a point now forgets a point type remembered for handle
drags, as opening and closing the outline already did. `shape.addPoint` on
the first point of an open outline no longer splits the closing edge of
another outline later in the same path.
2026-10-24 13:07:42 -07:00
Marco Arnulfo 2e54272415 Show: Ctrl+wheel zooms the slide in and out around the pointer (#1286)
Cmd+wheel on a Mac. Four notches double the zoom, up to 4x; zooming back to the whole slide leaves the zoom. The plain wheel still turns the page, and the trackpad pinch is still not a show gesture.
2026-10-24 21:56:17 +02:00
dotCooCoo a7ab15d942 Edit Points: Undo and Redo bring back the point type remembered with each step
Undo restored the outline but kept the point type that a handle drag with
Shift, Ctrl or Alt, or a Point Type choice, had set, so the next drag
without a modifier applied the undone type again. Each undo step now keeps
the point type remembered with the document it restores, and Undo and Redo
put it back, including a Straight or Corner choice that the handles alone
do not show. A command that fails leaves the remembered type as it was, and
the type applies only to the shape it was chosen on.
2026-10-24 12:33:33 -07:00
NetterSilverandClaude Haiku 5.5 8145cc1bac Treat curly apostrophe as ASCII in spell check
Found while testing crates/engine/src/spell.rs:693 (paragraph_words) and :353 (Dictionary::check)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 19:24:20 +00:00
dotCooCoo a9cafe64fc Docs: Play From and the .potx/.ppsx content types are no longer listed as missing
The Animation Pane row in ui-parity.md said the pane cannot preview from
the selected effect, but its button reads Play From and starts there
(animpane_tests). gaps.md, file-format-parity.md and the parity checklist
said .potx and .ppsx are saved with the .pptx content type, but
deckcraft_pptx::export_as writes the template and slide-show content
types, as a test in crates/pptx/tests/it/roundtrip.rs checks. The
gaps.md entry now covers only the macro-enabled variants. The checklist
row stays partial, and cargo xtask parity leaves the summary unchanged.
2026-10-24 11:58:09 -07:00
dotCooCoo 052afb3efd Status bar: the zoom slider reports percent, and Reading View reports when it is on
The zoom slider's number and range were the natural log of the zoom, so
a screen reader read 0 at 100% and set the zoom in log units. The slider
now runs from 10 to 400 percent on a log scale, and its number is the
percentage its text shows. Reading View reports itself as selected when
the view is Reading View, as the other view buttons do.
2026-10-24 11:12:04 -07:00
dotCooCoo 8f31193cc0 Edit Points: Esc before releasing the mouse button cancels a point or handle drag
Esc during a vertex or handle drag in Edit Points used to leave Edit
Points while the drag went on: the point kept following the pointer and
the release added an undo step. Esc now ends the drag and puts the shape
back as it was at the press, with no undo step, and Edit Points stays on.
Esc without a drag still leaves Edit Points.
2026-10-24 10:52:09 -07:00
Marco Arnulfo 9341ae0616 Shapes: Arrow: Notched Right cuts its notch as deep as presetShapeDefinitions give it, parallel to the head 2026-10-24 19:26:42 +02:00
Marco Arnulfo 9e9e13fc2c Files: saving as Outline/RTF keeps every paragraph of a slide title 2026-10-24 19:11:59 +02:00
Marco Arnulfo 1525dae1bd Files: opening an RTF outline keeps non-breaking hyphens 2026-10-24 19:08:05 +02:00
Marco Arnulfo 53bdcbafef Files: opening a Word RTF outline skips the list bullets and keeps each item's indent 2026-10-24 19:04:10 +02:00
Marco Arnulfo 9830b57fbe Shapes: a speech bubble's wedge mirrors with its tip, at 7..10 twelfths on the far side as presetShapeDefinitions give it 2026-10-24 18:50:57 +02:00
dotCooCoo 00f9df7d8d Edit Points: Shift, Ctrl or Alt while dragging a handle makes the point smooth, straight or corner
A handle drag in Edit Points ignored the modifier keys: the point kept
the type it had. In PowerPoint, Shift, Ctrl or Alt held during the drag
sets the point's type.

Shift held while dragging a point's handle now makes it a smooth point.
Ctrl (Cmd on the Mac) makes it a straight point, and Alt (Option) makes
it a corner point. The handle drag passes that type to shape.moveHandle,
and the point keeps it for the next drag without a modifier, as after
Smooth Point, Straight Point or Corner Point in the right-click menu.
2026-10-24 09:38:54 -07:00
dotCooCoo 68c21778ef Merge main into feat/table-cell-range-select 2026-10-24 09:29:12 -07:00
dotCooCoo 0c0cce3764 Merge main into feat/autoformat-lists 2026-10-24 09:29:11 -07:00
dotCooCoo b8f707165b Status bar: screen readers can name its controls and read the slide counter and status message
The status bar's Fit, Zoom In, Zoom Out, Slide Show, Reading View, Slide
Sorter and Normal buttons and the language name reported no widget info,
the zoom percentage reported only its own text as a label, and the zoom
slider had no name, so a screen reader announced the buttons and the
language without a name. The slide counter and the status message were
painted text with no accessibility node, so a screen reader could not
read them.

Each icon button now reports its tooltip as its name, and the four view
buttons also report whether their view is the one shown. The zoom
percentage is the "Zoom level" button with the percentage as its value,
and the slider is "Zoom" with the percentage as its text value. The
language name is the "Language" button with the language as its value.
The slide counter and the status message are labels with the text they
show.
2026-10-24 08:50:49 -07:00
dotCooCoo 205ca7dfc0 Text: the Columns dialog sends only the fields that changed
With several text boxes selected, the dialog shows the first one's
values. OK sent both Number and Spacing, so changing one of them also
gave every box the first box's value of the other. OK now sends only
the fields that differ from what the dialog showed, and with neither
changed it closes without an edit.
2026-10-24 08:23:17 -07:00
dotCooCoo 7d4bc14c1d Control server: refused connections drain on their own threads
The accepting thread drained each connection it refused as busy for up
to 250 ms before accepting the next, so refused peers that stayed
connected without writing delayed every connection after them. Each
drain now runs on a thread of its own, up to 16 at once; past that, a
refused connection gets the busy reply and is closed without a drain.

connection_limit_refuses_then_frees_slot now uses a 30 s idle timeout:
its first client sends nothing, and under load the 300 ms timeout
closed it before the third client connected.
2026-10-24 08:23:14 -07:00
Dominik Strebel 8236a9441b docs(roadmap): status line names the 2026-10-24 progress-log entry 2026-10-24 17:16:58 +02:00
NetterSilverandClaude Haiku 5.5 61b3108be7 Reject invalid index in animation remove, move, timing and options
Found while testing crates/engine/src/cmd/animation.rs:232-246 (remove); same pattern in each_target :265 and move_anim :250

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:35 +03:00
dotCooCoo c207d6eb0f Tests: the Notes-button test clicks where the button is laid out, so it passes without Inter
status::tests::a_long_language_name_leaves_the_notes_button_clickable
clicked x 150 for the gap before the status bar's controls and x 200 for
the Notes button. Those positions hold for Inter. With a craft-fonts
checkout that has no fonts/latin-manifest.txt, the interface text is set
in BIZ UDPGothic. Labels set in BIZ UDPGothic are wider than in Inter,
so the Notes button starts at x 149. The first click then toggled the
notes pane and the second toggled it back.

The status bar's Notes and Comments buttons now report widget info with
their names. The test reads the Notes button's rect, clicks midway
between x 132 and its left edge, and then clicks its center.
2026-10-24 06:25:07 -07:00
dotCooCoo e89c824c6d Text: a Columns dialog sets the number of columns and the spacing between them
Home > Columns offered 1, 2 or 3 columns, always 18 pt apart, and Format
Shape > Text Box had the same three buttons. The spacing could not be
seen or changed, and picking a number reset it.

Home > Columns > More Columns... and a Columns... button in Format Shape
> Text Box now open a Columns dialog with Number (1 to 16) and Spacing in
inches, filled in from the text box. OK applies both in one undo step.
A Spacing left as shown keeps the text box's exact value.

format.columns with no fields opens the dialog. A key left out keeps the
text box's own value instead of resetting it to 1 column or 0 pt, and a
count that is not a whole number from 1 to 16, or a spacing that is not
a number, is an error. Before, an out-of-range count was clamped, a
count that was not a whole number meant 1 column, and a spacing that
was not a number meant 0 pt.
2026-10-24 05:57:36 -07:00
aiongg 38d2ade2b7 Merge pull request #1260 from aiongg/r1-batch1
Integration batch 1: 35 contributor PRs (charts, shapes, slides, text editing, dialogs, outline files)
2026-10-24 08:38:28 -04:00
Dominik Strebel a8f1573fba fix(pptx): an object saved as its preview picture keeps the fallback picture's fill 2026-10-24 14:29:30 +02:00
NetterSilverandClaude Haiku 5.5 53eda1e8df Reject multiple leading '#' in Rgba::from_hex
Found while testing crates/color/src/lib.rs:37

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:43 +02:00
dotCooCoo 79c1d578be Control server: a peer refused as busy still reads why, and two tests no longer race
When every connection slot was taken, the accepting thread wrote the busy
reply and dropped the socket. A peer that had already written a line saw a
reset instead of the reason. The busy path now closes the same way a
rejected line does: it sends the FIN and drains the socket for at most
250 ms, or the write timeout when that is shorter.

The drain set its read timeout on the writing handle and read through a
clone of the socket. On Windows, a read timeout set on one handle of a
try_clone pair can fail to apply to reads through the other. On Windows 11
a rejected peer that stayed connected then kept its slot until the idle
timeout (10 minutes by default) instead of the write timeout. The timeout
is now set on the handle that reads.

Two tests failed intermittently. a_pipelined_peer_still_reads_why_its_line_was_rejected
connected 12 peers through 2 slots, and a slot comes back only after the
thread serving the previous peer ends; each peer now has a slot of its own.
blocked_ui_times_out_and_full_queue_is_busy relied on a 50 ms sleep to
order two requests from two connections; it now accepts either order.
2026-10-24 04:59:06 -07:00
dotCooCoo 44d5a0e7b9 Tests: UI tests that render with wgpu take turns, so the Windows test run no longer crashes
Harness::render creates a wgpu device on its first call, and egui_kittest
prefers a CPU adapter. When cargo test runs several of these tests on
parallel threads, the deckcraft-ui-egui test binary can exit with
0xc0000005 (STATUS_ACCESS_VIOLATION), so cargo xtask ci stops at its test
step on Windows without cargo-nextest.

The 16 tests that call render now hold one mutex for their whole run
(crate::gpu_lock::gpu_lock, as in photocraft's testkit), so only one of
them runs at a time.
2026-10-24 04:05:13 -07:00
oracola91 920925abae PDF export keeps only the links the slide show opens
A denylist can't keep up with OS protocol handlers: ms-msdt:, ms-word:
with a share path, ldap: and file:///%5C%5Chost/share still went into
exported PDFs. PDF export now writes a URL link only when safe_url
allows it, the check the slide show already uses on a click: http,
https and mailto. Links between slides are unchanged, and any other
link's text stays on the page without the link.

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 10:13:35 +00:00
Dylan M. Taylor 7be391a1be fix(pptx): harden import resource budgets, deduplication, and lookup caching (#1283)
* fix(pptx): harden import resource budgets, deduplication, and lookup caching

- Chart sidecars (Finding 2): Enforce an import-wide retained byte budget
  and cache captured sidecars by chart part to prevent memory amplification.
- Content types cache (Finding 6): Cache parsed [Content_Types].xml defaults
  and overrides in Package.ct_cache.
- Text run splitting and font lengths (Finding 8): Bound typeface attribute
  lengths and cap paragraph runs to prevent multiplication on text splitting.
- Inner namespace decls (Finding 15): Use HashSet/HashMap indexing and cap
  inner declarations to 1,000.
- Slide and notes deduplication (Finding 16): Deduplicate slide paths and
  cache parsed notes slide bodies by target.
- Diagram media caching (Finding 22): Cache diagram media payloads in
  imp.media_by_part.
- Master slide deduplication (Finding 28): Deduplicate master paths before
  loading.
- Layout target deduplication (Finding 30): Deduplicate layout targets
  across master slides.
- Chart value axis indexing (Finding 32): Index valAx by axId using a HashMap.
- ChartEx series output budget (Finding 34): Charge series point vectors
  against an aggregate output budget and cap series count.
- ChartEx point allocation bounds (Finding 36): Allocate point vectors
  only to actual point span bounded by budget rather than declared ptCount.
- SmartArt recursion depth (Finding 38): Limit SmartArt recursion depth to 8.

* fix(pptx): address review feedback on sidecar cache, series budget, and inner decls

* fix(pptx): preserve cached sidecar bytes and non-empty category fallback
2026-10-24 06:01:30 -04:00
dotCooCoo e8910cb253 Paste: a table, bullet or slide from another deck keeps its own pictures and sounds
Copy listed the media of pictures, media clips, 3-D models and shape
fills only, and Paste rewrote the ids of pictures, media clips, opaque
previews and shape fills only. Picture fills in table cells and table
backgrounds, picture-filled text runs and picture bullets were left off
the clipboard. In another deck they pointed at a missing picture, or at
that deck's own picture when it used the same media id. A copied slide
lost its transition and animation sounds in the same way.

Paste Special > Picture gave clashing media fresh ids but drew the
shapes with the old ids, so a clashing id drew the open deck's picture.
Review > Compare > Accept Revision of a compared deck's slide copied the
same partial set, and missed the slide background as well.

Copy, Paste, Paste Special > Picture and Accept Revision now find and
rewrite media ids with the media_refs walkers that slide.reuse uses.
2026-10-24 02:48:19 -07:00
aiongg 528af4e503 Integration: a rejected positional revision is not turned into a deletion when a slide is inserted before it 2026-10-24 09:40:33 +00:00
aiongg 202255cbbb Integration: restore chart.rs CRLF line endings 2026-10-24 09:39:13 +00:00
aiongg 44b33a2f57 Integration: a subnormal chart data range gives a finite nice scale 2026-10-24 09:38:51 +00:00
aiongg 56b15df0ab Integration: a huge RTF left indent no longer overflows when the outline level is derived 2026-10-24 09:38:19 +00:00
NetterSilverandClaude Haiku 5.5 ef6819b6fd Reject '+' sign in OPC target percent-decoding
Found while testing crates/pptx/src/opc.rs:263-277 (percent_decode, used by resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 09:31:47 +00:00
aiongg a453292729 Merge main into the integration batch 2026-10-24 09:20:03 +00:00
aiongg 910b03f4a1 Integration: the GIF play/pause click works only while the pointer is awake and the button is shown 2026-10-24 09:19:46 +00:00
aiongg e679fa500a Integration: right-clicking a control handle in Edit Points keeps the picked point and its menu 2026-10-24 09:17:30 +00:00
aiongg 027075d352 Integration: a rejected positional revision stays rejected after a slide is inserted or deleted in the open deck 2026-10-24 09:16:08 +00:00
Dylan M. Taylor f86928d0d5 perf(pptx): index export lookups and deduplicate relationship parts (#1281)
- Row 31: Use HashMap for comment author lookup in author_slot
- Row 37: Index preserved parts by ID before resolving graphic frame relationships
- Row 10: Index kept chart parts by normalized path and avoid duplicate payload scans
- Row 39: Update existing relationship parts instead of appending duplicates in PackageOut
2026-10-24 05:16:07 -04:00
aiongg 2bbfe46d3e Integration: slides with two visible pictures get no layout ideas 2026-10-24 09:14:11 +00:00
aiongg 9dcb7bab03 Integration: the automatic-date test reads the clock before and after the command so a tick of the second cannot fail it 2026-10-24 09:13:29 +00:00
aiongg 75bec53e08 Integration: RTF import drops NUL units and masks \u to 16 bits so the surrogate marker cannot be forged 2026-10-24 09:11:34 +00:00
Dylan M. Taylor c55a9f771e security(metafile): harden EMF+ parsing, clipping complexity, and memory accounting (#1280)
* fix(metafile): harden EMF+ parsing, clipping complexity, and memory accounting

- Row 72: Use checked tail slicing in comment header offset calculations
- Row 78: Break FillRects and DrawRects loops early when canvas is full
- Row 75: Pre-check dimensions in decode_compressed before RGBA pixel allocation
- Row 85: Account for EMF+ object table image/path memory against MAX_BYTES
- Row 42: Replace O(N^2) prefix copying in wrap_line with linear measurement
- Row 43 & 54: Share immutable clip regions Arc across placed items and charge canvas regions
- Row 48: Bound text transformation scale and glyph curve geometry in Picture::place and draw_text
- Row 52 & 68: Impose work budget bounds on Sutherland-Hodgman polygon clipping and bitmap masking

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

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

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 10:41:30 +02:00
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
dotCooCoo 9888d7c90e Tables: dragging across cells selects a range of cells
While the caret is in a table cell, a drag into another cell of the
table now selects the cells between them. The range takes in every
merged cell it touches, and the canvas shades the selected cells.
Moving back into the first cell returns to a text selection there.
Shift+arrow moves the cell the range was extended to, and the cell it
started from stays.

With a range selected, run and paragraph formatting apply to those
cells only. Delete clears their text and keeps the cells and the table,
and Copy and Cut take their text, with a tab between cells and a line
per row. Shading, Borders, Cell Margins and Align take in the merged
cells the range touches. A press in the table starts again in the cell
under the pointer.
2026-10-24 01:29:58 -07: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
dotCooCoo 05f2a84400 Text: text.insert reports the caret where AutoCorrect leaves it
text.insert returned the caret from before AutoCorrect ran. After a
typed space started a list, it reported (0, 2) for `* ` while the caret
was at (0, 0) in the emptied paragraph, and after `(c) ` it reported
(0, 4) for the two-character `© `. It now reports the caret from the
selection after AutoCorrect.
2026-10-24 00:44:36 -07:00
aiongg 9c31b567da Integration: #978 media-controls test turns presenter view off so its embedded window does not cover the bar 2026-10-24 07:28:17 +00:00
NetterSilverandClaude Haiku 5.5 ee248bb5cb Decode numeric tab entities in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:304 (numeric_char, used by decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 07:18:46 +00:00
aiongg 30755b3ff7 Integration: #1244 Notes-button test starts with the pane closed and clicks the gap the language name must not cover 2026-10-24 06:53:15 +00:00
aiongg f639ef986e Integration: #927 Header & Footer dialog: reading the format must not store the default as picked 2026-10-24 06:47:20 +00:00
aiongg ded9094f59 Integration: crates/model/src/chart.rs keeps main's CRLF line endings 2026-10-24 06:29:33 +00:00
aiongg 7c1ae73ead Integration: #565 collapse the nested if in rtf_to_outline (clippy) 2026-10-24 06:29:33 +00:00
aiongg 36cd258bd2 Merge PR #883: PPTX: paragraph builds edited in the reference app keep their clicks
ROADMAP.md: union of log rows. docs/animation-parity.md: main's header; the by-paragraph row from the PR, the triggers row from main; both revision rows.
2026-10-24 06:27:19 +00:00
aiongg 8957e193e9 Merge integration track r1-t02 (dialogs, view, text editing, panes, status bar, other)
ROADMAP.md, docs/gaps.md: union of both tracks' log rows.
2026-10-24 06:26:36 +00:00
aiongg 820b163067 Merge integration track r1-t01 (charts, tables, render, shapes, slides, slideshow, outline formats) 2026-10-24 06:26:23 +00:00
aiongg 80e5506456 Integration: cargo fmt 2026-10-24 06:25:28 +00:00
aiongg 3a37fb6b7e Integration: #565 rtf_to_outline never splits a multi-byte character after \u or \' escapes 2026-10-24 06:18:22 +00:00
aiongg abd8768b65 Merge PR #565: Files: save and open outlines as RTF
Conflicts:
crates/engine/src/cmd/file.rs: export spec keeps main's transparentBackground and adds rtf; probe_clips (main) and outline_text (PR) both kept
docs/file-format-parity.md: PR's outline row (RTF now read) with main's .md wording, main's PNG row
2026-10-24 06:18:03 +00:00
aiongg 882f1cbdbc Merge PR #1075: Indent table rows in outline export so they stay on their slide 2026-10-24 06:17:37 +00:00
aiongg bb5da2036d Merge PR #978: Hide the slide show media controls when the pointer is idle (#967)
Conflicts: crates/ui-egui/src/show.rs (test module: kept both sides' tests)
2026-10-24 06:17:37 +00:00
aiongg 5b5fd5eabe Merge PR #1109: Reuse Slides: choose which slides of the other file to insert
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:17:26 +00:00
aiongg 2693336155 Merge PR #809: Design: Browse for Themes brings a template's slide master and layouts (#807)
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:17:23 +00:00
aiongg f52c911959 Integration: #927 Header & Footer dialog sends dateFormat only when picked; set_date doc comment back above set_date
Without it, Apply rewrote every slide's existing date field to datetime1.
2026-10-24 06:17:19 +00:00
aiongg 0313e0128a Merge PR #850: Review: reject revisions after Compare, one or all
ROADMAP.md, docs/gaps.md: kept main's lines and the PR's single added line in each table.
2026-10-24 06:16:50 +00:00
aiongg 5880086522 Merge PR #927: Header & Footer: the automatic date takes a format 2026-10-24 06:16:45 +00:00
aiongg b7c505a389 Merge PR #1254: Reject invalid index in slide.hide and slide.layout
Conflicts: crates/engine/src/tests.rs (appended both sides' tests)
2026-10-24 06:16:45 +00:00
aiongg 987f49ac03 Merge PR #1252: Reject '+'-signed numeric character references in HTML paste 2026-10-24 06:16:43 +00:00
aiongg dd9111ba20 Merge PR #384: Web: point web app favicon to deckcraft.ico
contributors/people.toml: kept both new entries.
2026-10-24 06:16:43 +00:00
aiongg 92724e02bb Merge PR #1205: Design Ideas: no ideas for slides with diagrams, connectors or other graphics (#1200)
Conflicts: crates/engine/src/cmd/ideas.rs (test module: kept both sides' tests)
2026-10-24 06:16:39 +00:00
aiongg 0b6ea6a287 Integration: #1244 test that a long language name leaves the Notes button clickable 2026-10-24 06:16:38 +00:00
aiongg 29cc5f83bd Integration: #833 drop the leftover PNG debug hook from the Edit Points menu test 2026-10-24 06:16:27 +00:00
aiongg c480b74a3f Merge PR #833: Shapes: Edit Points — Straight/Curved Segment and Open/Close Path from the right-click menu
Conflicts: ROADMAP.md docs/gaps.md docs/ui-parity.md (union of both sides' rows)
2026-10-24 06:16:21 +00:00
aiongg 2fd32751cc Merge PR #1251: Fix arrange.distribute span to use the selection's full extent 2026-10-24 06:16:21 +00:00
aiongg 78afd2fc45 Merge PR #1245: Title: arrange.group: nest groups at most 32 deep 2026-10-24 06:16:18 +00:00
aiongg b280f7755d Merge PR #846: Canvas: the Format and Animation Painter override the cursor (#830)
Conflicts: crates/ui-egui/src/canvas.rs (cursor match: kept #806's resize_cursor arm inside #846's painter-armed wrapper)
2026-10-24 06:16:18 +00:00
aiongg 84702f3810 Merge PR #806: Canvas: the resize cursor follows the handle's rotated diagonal (#745)
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:16:06 +00:00
aiongg be5413826c Merge PR #848: Render: quick styles render on empty placeholder text boxes (#832)
Conflicts: ROADMAP.md (union of rows)
2026-10-24 06:16:06 +00:00
aiongg 2109dfa03c Merge PR #1244: Status bar: keep a long language name from overlapping 2026-10-24 06:15:59 +00:00
aiongg e6f7648219 Integration: #945 allow unwrap and expect in the thumbs tests module 2026-10-24 06:15:59 +00:00
aiongg 3aa8f4b8b1 Merge PR #1016: Render: table body text on tinted cells picks the theme colour that contrasts with the fill, so default tables stay readable on dark themes 2026-10-24 06:15:56 +00:00
aiongg 4f56789f45 Merge PR #945: Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)
ROADMAP.md: kept all table lines.
2026-10-24 06:15:56 +00:00
aiongg f2e2fcaba4 Merge PR #1015: Chart text can be coloured (chart.options textColor, format.color) and follows the theme by default
Conflicts: crates/engine/src/cmd/insert.rs (kept main's apply_bin_options / apply_subtotals_and_bars helpers and added the textColor assignment)
2026-10-24 06:15:53 +00:00
aiongg 6971d55345 Merge PR #799: Text boxes: a click on the frame selects the box whole (#787)
docs/ui-parity.md: kept main's status header lines; the PR's older header dropped, its new table row kept.
2026-10-24 06:15:50 +00:00
aiongg aed303e504 Integration: #956 chart.options dataLabels resets the series' own settings
Without it, turning Data Labels on did nothing for an imported chart whose series read showVal 0.
2026-10-24 06:15:41 +00:00
aiongg c7388bd1ed Merge PR #320: Text: Tab no longer ends text entry in lists (#93)
crates/ui-egui/src/lib.rs: keyboard() keeps main's image-paste event scan and adds the PR's focus surrender (one shared modal flag); both new tests kept (typing_goes_on_after_tab_in_a_text_box, tab_in_an_edited_list_keeps_the_keyboard).
2026-10-24 06:15:41 +00:00
aiongg cac379b5ee Merge PR #956: Charts: a series' own data-label setting decides whether its labels show (#868)
Conflicts: crates/model/src/chart.rs (kept series_data_labels and main's waterfall_value_range split; value_range stays where main has it)
2026-10-24 06:15:05 +00:00
aiongg 7fd34fb359 Merge PR #813: engine: a hostile text selection can't drive paragraph commands past the body 2026-10-24 06:15:03 +00:00
aiongg 5827d3b4c1 Merge PR #904: Clamp text.selectParagraph paragraph index to avoid overflow 2026-10-24 06:15:03 +00:00
aiongg f6597e0259 Merge PR #1043: Replace Fonts test: a window wide enough to hold Home's ribbon 2026-10-24 06:15:03 +00:00
aiongg f8a70063f7 Merge PR #965: Tests: Ctrl+A in the slide sorter selects every slide (#936)
ROADMAP.md: kept both table lines.
2026-10-24 06:15:03 +00:00
aiongg 601a20ecf6 Merge PR #805: Preferences: checkboxes and slider rails show in light mode 2026-10-24 06:14:58 +00:00
aiongg ca0e1a300a Merge PR #1208: Theme: text fields show in the light theme (#1204)
crates/ui-egui/src/lib.rs: kept both test modules (field_frame_tests, find_dialog_tests).
2026-10-24 06:14:58 +00:00
aiongg ee721e26dd Merge PR #1198: Make chart axis flatness thresholds relative to data magnitude 2026-10-24 06:14:53 +00:00
aiongg a1c92dc6c5 Merge PR #1146: Charts: bars draw their series' pattern fill and no fill
Conflicts: ROADMAP.md (union of progress-log rows)
2026-10-24 06:14:53 +00:00
aiongg a774f01199 Merge PR #962: Tests: the Options dialog's Cancel, Esc and the close button restore the settings (#878)
crates/ui-egui/src/dialogs.rs: kept both test modules (about_credits_tests, preferences_tests).
2026-10-24 06:14:48 +00:00
aiongg e56c62c260 Merge PR #793: UI: close About dialog on Cancel and test Options Cancel from File tab (#782) 2026-10-24 06:14:39 +00:00
dotCooCoo e04ef2b4a8 Text: AutoFormat starts bulleted and numbered lists as you type
A paragraph that starts with *, -, --, >, <>, 1., 1), I., i., A., a.
or a) followed by a space or tab now becomes a bulleted or numbered
list in that style through format.bullets or format.numbering, and the
typed lead and space are removed. The keystroke is one undo step.
Backspace right after the change puts the typed text and the
paragraph's formatting back.

Tools ▸ AutoCorrect has "Automatic bulleted and numbered lists" to turn
it off, and text.autocorrect takes and reports it as lists.
2026-10-23 22:06:56 -07: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
NetterSilverandClaude Haiku 5.5 6d7b32351f Reject invalid index in slide.hide and slide.layout
Found while testing crates/engine/src/cmd/slide.rs:376 (hide); same pattern at slide.rs:445

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 02:16:19 +00:00
NetterSilverandClaude Haiku 5.5 fc914a059c Reject '+'-signed numeric character references in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:301-304 (numeric_char, used by decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:23:11 +00:00
NetterSilverandClaude Haiku 5.5 18e8bb0ded Fix arrange.distribute span to use the selection's full extent
Found while testing crates/engine/src/cmd/arrange.rs:325-327 (distribute)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:07:34 +00:00
Zach BerryandClaude Fable 5.1 52bf97d983 Render: table body text on tinted cells uses the theme's dark colour, so it stays readable on dark themes
The medium2 (default), medium4 and banded light1 table styles tint body cells towards white
but gave their text no colour, so it inherited the theme's text colour: light on a dark theme
such as Nocturne, and nearly invisible on the light tint. PowerPoint's medium styles set that
text to dk1. DeckCraft's dark themes keep the dark colour in lt1 (Nocturne has dk1 white and
lt1 near-black, relying on the default tx1->dk1 map), so the look picks whichever of dk1 and
lt1 contrasts more with the cell's actual fill. Cells with no fill keep inheriting, and the
"grid" border keeps the text colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:58:37 -05:00
Zach BerryandClaude Fable 5.1 aaba9febc2 Progress log: chart text colour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:58:02 -05:00
Zach BerryandClaude Fable 5.1 0c510426af Chart text without a colour of its own takes PowerPoint's default grey
The theme's bare text colour made chart text black on a light theme,
where it used to be 595959. PowerPoint's default is `tx1` at lumMod 65000
and lumOff 35000: 595959 on a light theme, a light grey on a dark one, so
the dark-theme fix stays. The render test now checks the light-theme title
is dark but not black; the chart.options doc says what null gives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 3ac39f9bb6 Chart text colour is written to the chart part, so an imported chart keeps it
`Chart::text_color` was read from a .pptx (the axes', legend's or chart's
`c:txPr`) but never written, and any edit drops the kept part and rebuilds
it from the model, so a save after an edit lost the colour. The writer now
puts it where PowerPoint keeps it: `c:txPr` on both axes, the legend, the
data labels and the chart root, and the run defaults of the title's rich
text (chart and axis titles). Nothing is written without a colour, so the
theme default still applies on reading.

Round-trip tests: a scheme colour with a lumMod, an RGB colour and no
colour come back as set; a chart read with a `tx1` 65 %/35 % colour on its
axis keeps it through a title edit and a rebuild of the part.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 30b7eff487 Pie data labels take the chart's text colour when it has one
A textColor set on a chart (chart.options, or Font Color with the chart
selected) coloured the title, axes and legend but left a pie's data labels
white. They stay white by default, on the slices, and follow a colour the
chart's text was given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 be9bf48eb2 Chart text without a colour of its own takes the theme's text colour
A chart's title, axis labels, legend and data labels were drawn in a
fixed grey (0x595959) whenever `text_color` was unset, which is
near-invisible on a dark theme. The renderer now resolves the theme's
tx1 instead, so a chart is readable by default on Nocturne as well as
on Harbor; an explicit colour still wins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 1b03ae95de format.color on a selected chart colours the chart's text
Font Color with a chart selected answered and changed nothing: a chart
has no text runs to colour. It now sets the chart's text colour (title,
axes, legend and data labels), the same thing chart.options {textColor}
sets; a mixed selection still colours the other shapes' runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 a48a2d706a chart.options takes textColor so a chart's text can be coloured
On a dark theme a chart's title, axis labels, legend and data labels
render in a fixed grey and nothing could change them, though the model
already carries `text_color`. `chart.options {textColor}` now sets it
(#RRGGBB, accent1…accent6, tx1…, or null for the default) and refuses a
string that isn't a colour. docs/mcp.md mentions it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05: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
NetterSilverandClaude Sonnet 5.5 866f349fbf fix: centre linear trendline sums so offset x ranges still fit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 23:31:52 +00:00
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
NetterSilverandClaude Sonnet 5.5 8789394de1 fix: keep chart axis steps positive and finite and drop absolute floors in rendering
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:21:54 +02:00
NetterSilverandClaude Sonnet 5.5 9679c6e16f fix: Name registered table commands in errors and reject invalid cell params
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:13:55 +02:00
NetterSilverandClaude Sonnet 5.5 9b2ab3d2b8 test: give outline table-row regression test a titled slide and assert one slide
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:07:54 +02:00
SusieeTheFemboyandClaude Opus 5.5 2bc30fbd9a H.264: assert the r clamp's endpoints directly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:42:20 +02:00
SusieeTheFemboyandGLM-5.3 57acc28df3 H.264: the inverse transforms clamp coefficients to the spec r range
The dequantisation wraps by design (huge_levels_dequantise_without_
overflow_panics), so idct4_add and idct8_add receive any i32 coefficient,
and their plain-i32 butterflies panicked a debug build with "attempt to
add with overflow" on extreme values. A dequantised r outside
-2^15..2^15-1 leaves the transforms' output unspecified (ITU-T H.264
8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
bit-exact for every conforming stream — instead of panicking.

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

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 00:34:06 +02:00
KataccandClaude Opus 5.5 8117d5b710 arrange.group: nest groups at most 32 deep
Grouping a group again and again had no limit, but a .deckcraft saved
with 39 or more nested groups could not be opened again (the JSON
reader's recursion limit), and past 64 levels shape walks such as
format.languageAll stop. arrange.group now refuses to nest past 32,
the depth the PPTX reader reads and the renderer draws, and leaves the
slide unchanged. The test builds the deepest allowed deck (a table at
the bottom) and checks it saves and reopens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:21 +03:00
KataccandClaude Opus 5.5 50667e925e Doc comment for the status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03:00
KataccandClaude Opus 5.5 284db01d5e Status bar: clip the language label before the controls on the right
In a narrow window a long language name ("… (not checked)") painted
over the Notes and Comments buttons, and its click area covered them.
The label is now clipped where the controls begin, and only its visible
part opens Review › Language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03:00
KataccandClaude Opus 5.5 8212c1a805 Status bar: keep the save message clear of a long language name
The transient message ("Saved") was painted at a fixed x of 290, so
"English (United States) (not checked)" ran under it. The language and
the message now each start at their usual column or after the item
before them, and the message is clipped before the controls on the
right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03: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
midasmatterhorn 077682ed4c engine: merge main and cover direct hostile paragraph selections 2026-10-23 21:22:14 +03: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
xc4pr d86f7aed75 Theme: the field test only counts a fill or outline that is actually painted (#1204) 2026-10-23 18:52:55 +02: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
xc4pr e126fad9a7 Design Ideas: decide whether an empty box shows from its resolved fill and outline (#1200) 2026-10-23 18:17:39 +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 6d312e0722 Design Ideas: an empty text box that shows also blocks the ideas (#1200) 2026-10-23 02:27:59 +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 b61898bba5 Theme: text fields show in the light theme (#1204)
In the light theme a text field had the same white fill as the dialog,
pane or ribbon it sits on and no outline (nor a visible focus outline),
so only its label or hint showed. Fill text fields with a light grey, as
the dark theme already sets them apart with a darker well. The File tab
keeps its outlined white fields.
2026-10-23 02:00:59 +02:00
xc4pr adcfa70b4a Design Ideas: no ideas for slides with diagrams, connectors or other graphics (#1200) 2026-10-23 00:45:54 +02:00
xc4pr 7858d589cd Design Ideas: switching ideas removes the Full-bleed title band (#1201) 2026-10-23 00:45:54 +02:00
NetterSilverandClaude Haiku 5.5 53972cf37c Make chart axis flatness thresholds relative to data magnitude
Found while testing crates/model/src/chart.rs:775-794 (axis_scale); same pattern in nice_scale :817

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:07:43 +00: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
NetterSilverandClaude Haiku 5.5 c0128a0eb8 Use scale-relative degeneracy check in linear trendline fit
Found while testing crates/model/src/trendline.rs:281-313 (linreg)

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:46:51 +02:00
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 08b7b5eb15 Charts: bars draw their series' pattern fill and no fill
A bar series' own fill was read only when solid or a gradient: a pattern
(a:pattFill) was dropped, and an a:noFill series was read but drawn in the
palette's colour anyway, because the chart renderer turned every fill into
one colour and fell back to the palette for anything but solid. PowerPoint
for Mac draws the pattern, leaves an a:noFill bar empty and draws its
outline (c:spPr/a:ln) where it has one: a synthetic column chart with a
red-on-yellow dkDnDiag series, an empty series with a 3 pt blue outline
and an empty one without, exported as PDF, shows exactly that, legend
keys included.

- The chart reader reads a:pattFill (dml::pattern, shared with shapes);
  the chart writer writes it back for an edited chart.
- Bars and their legend keys paint with the series' or point's own fill:
  a colour, a preset pattern (pattern::fill) or nothing, then the series'
  solid outline at its width.
- Lines, areas and markers, which take one colour, use a pattern's
  foreground colour instead of the palette's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:02:09 +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
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
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
FaabyyandClaude 3cd6a815ca Roadmap: record reuse-picker-1103
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 08:14:34 +00:00
FaabyyandClaude fdfdb9e058 Reuse Slides: choose which slides of the other file to insert (#1103)
Reuse Slides… used to insert every slide of the picked presentation. It now opens a list
of that presentation's slides (number and title, all ticked), with Select All / Select None
and a Keep source formatting option, and inserts only the ticked ones after the current
slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took
`slides`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 08:04:19 +00: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
Sazid Al Bayazid dc69e57600 Tests: Cancel restores every Options setting, not only the first (#878)
A report on 0.7.0 said the Options dialog's Cancel did nothing, and a
reproduction on 0.8.0 found it working for the appearance toggle and the
name field, without trying the other controls. This covers the rest: the
Editing checkboxes (snap to grid, smart quotes, autocorrect) and the name
field go back with Cancel, like the first checkbox does.

Verified red with the restore of the settings taken out: the name keeps
what was typed.
2026-10-21 00:35:43 +06: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
NetterSilverandClaude Haiku 5.5 502fb2ba73 Indent table rows in outline export so they stay on their slide
Found while testing crates/format/src/lib.rs:660-665 (slides_to_outline) and :573-574 (outline_to_slides)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:03:20 +00: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
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
Sazid Al Bayazid 0405fb73bf Charts: a series' own data-label setting decides whether its labels show (#868)
The reader stored a series-level `c:dLbls/c:showVal` in
`Series.data_labels`, but every drawing path tested only the chart-wide
`Chart.data_labels`: a series that asked for labels got none, and a
series that asked for none got them. The series' own setting now wins,
and a bare `<c:showVal/>` (no `val` attribute) means true, as the
schema's default.

The plot group's own setting is respected too: a group that asks for no
labels keeps them off, even when another group of a combo chart asks for
them. Before, any group that asked turned the chart-wide setting on, and
the group that said no inherited it.

Tests: `crates/pptx/tests/chart_data_labels.rs` - a bare series
`showVal` shows that series' labels while its siblings stay quiet; an
explicit `val="0"` on the first series keeps it quiet under a chart-wide
on; a combo chart keeps each group's own setting; all three settings
round-trip. Red-proof: with the series-level setting ignored, the first
two fail; with the group's setting ignored, the combo chart's bar group
shows labels it asked to keep quiet.

The model gains `Chart::series_data_labels`, the one place that decides
whether a series' labels show; the render paths (bar, column, line,
area, pie, doughnut, radar, scatter) call it.
2026-10-20 09:31:04 +06:00
Sazid Al Bayazid 0b476e823d Tests: the Options dialog's Cancel, Esc and the close button restore the settings (#878)
The Options dialog applies every change as it is made, and closing it
with Cancel, Esc or the window's close button puts the settings back the
way they were. Nothing covered that, so a change to the dialog could have
dropped it unnoticed.

The kittest now covers all three ways out: a checkbox put back by Cancel,
the appearance and the interface size put back by Esc, the same checkbox
put back by the window's close button, and the dialog closing in each
case. Red-proof: with the Cancel restore removed the checkbox test fails
(the setting stays changed), with the Esc restore removed the appearance
test fails (it stays dark), and with the Esc/close restore removed the
close-button test fails (the setting stays changed).

ROADMAP progress-log row added.
2026-10-20 09:21:26 +06:00
Sazid Al Bayazid 52a9a45f6a Render: a quick style on an empty placeholder is covered by a test (#832)
The fix changed the empty-placeholder fill gate, but nothing locked the
behaviour in: a quick style's fill has to appear on an empty placeholder,
and a placeholder with no style at all still has to stay empty.

The new render test draws an empty body placeholder in the editor with a
quick style and checks that the accent fill is painted - every channel of
it - and that the same placeholder without a style stays white. With the
gate reverted to `s.fill.is_none()` alone the first assertion fails - the
placeholder stays white - so the test is red on the unfixed code.
2026-10-20 09:09:28 +06:00
Sazid Al Bayazid bc12031d70 Canvas: the resize cursor follows the handle's diagonal, whatever the shape (#745)
Two gaps in the resize cursor, both found by a review of the fix:

- The handle's direction was taken from the box centre to the handle, so a
  wide rectangle's corner pointed almost along an edge: a 400x80 shape's NW
  corner showed a horizontal cursor. A corner drags along the diagonal
  whatever the shape's proportions, so the direction is now normalised -
  the diagonal at 45 degrees - and only the shape's rotation and flips
  turn it.
- The cursor read the shape's own box, but the handles are drawn from the
  effective one, which a placeholder inherits from its layout. Such a
  placeholder showed the default arrow instead of a resize cursor.

The tests cover both: a wide shape keeps its corner cursors diagonal, and
a placeholder that inherits its box has a resize cursor. Red-proof: with
the aspect ratio back in the direction, the wide shape's NW corner shows
a horizontal cursor; with the shape's own box, the placeholder shows the
default arrow.
2026-10-20 08:33:22 +06: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
Hamza 4b474d77a9 Replace Fonts test: a window wide enough to hold Home's ribbon
Home's ribbon has grown since the test was written. At the harness's
1400x900 the Editing group starts at x=1398, so the Replace Fonts
button is two pixels wide on screen and a click at its centre lands
outside the window: the button is found and enabled, but never clicked,
and the dialog assertion fails. The ribbon does not scroll sideways, so
the window has to hold it; 1800x1100 is what the other full-ribbon
tests here use.

cargo xtask ci fails at `test` on main without this.
2026-10-20 00:28:19 +05:00
Sazid Al Bayazid 756ca23d31 Canvas: the Format and Animation Painter override the cursor (#830)
When the Format Painter or Animation Painter was armed, the mouse cursor
stayed as the default arrow. The user had no visual indication that the
tool was active and waiting for a click on a shape to apply the copied
format.

The canvas now sets the Copy cursor while either painter is armed, and
goes back to its usual cursor once the tool is done. The override takes
precedence over the other cursor assignments (resize, move, crosshair,
text), because the painter's whole purpose at that moment is to receive
the next click.

The kittest covers both painters: it hovers over the canvas, checks the
cursor is not the Copy one yet, arms the painter and checks it is. With
the painter override removed both fail - the cursor stays Move instead
of Copy.

ROADMAP progress-log row added.
2026-10-20 01:04:45 +06: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
Sazid Al Bayazid 37d69f0449 Tests: Ctrl+A in the slide sorter selects every slide (#936)
The sorter's Ctrl+A runs edit.selectAll with the slides scope, so every
slide in the deck is selected, as in PowerPoint. Nothing covered the key
press itself, so a change to the shortcut could have dropped the scope.

The kittest covers the whole way: the key press, the scope and the
selection it leaves behind. Red-proof: with the sorter's Ctrl+A no longer
asking for the slides scope, the selection stays at the one current slide
and the test fails.

ROADMAP progress-log row added.
2026-10-20 00:55:56 +06:00
Sazid Al Bayazid 8e83e8bb81 Render: quick styles render on empty placeholder text boxes (#832)
The render gate for an empty placeholder's fill checked only
s.fill.is_none(). A quick style that sets a style reference without
an explicit fill left s.fill as None, so the gate skipped the fill
even though the shape had been formatted — the style only appeared
after text was added.

The gate now also checks s.line and s.style: if any of the three has
a local override, the placeholder is considered formatted and the
fill is drawn. A truly unformatted empty placeholder still shows the
dashed outline and prompt.
2026-10-20 00:17:15 +06:00
Sazid Al Bayazid 3610cd6b6f Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)
The pane scrolled only by hand: past a screenful of slides, arrow-key
navigation left the current thumbnail out of view. When the slide changes,
the pane now scrolls it into view - minimally, only once the thumbnail has
left the viewport, as in PowerPoint, so a visible thumbnail never moves the
pane and the user's own scrolling stands. The pane's scroll offset is
recorded in UiState (skipped by serde) for the control channel and tests.
2026-10-20 00:16:40 +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
FaabyyandClaude Sonnet 5.5 8bc3d3f110 Text boxes: a click on the frame selects the box whole (#787)
A click on the frame band of an unselected or selected text box or
placeholder started text editing, so the box could not be selected,
moved, duplicated or deleted from its frame. The frame band now selects
the box whole, like PowerPoint; only a click inside takes the caret.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:25:14 +00: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
Faabyy a7833e094d #807: Browse for Themes brings a template's slide master and layouts
Design › Browse for Themes… with a .pptx or .potx now does what PowerPoint
does: the file's first slide master and its layouts replace the master the
current slide uses (a preserved master stays beside it), stretched to this
deck's slide size. Each slide on the old master moves to the new layout with
the same name, else the same kind, and keeps its content as Layout does. One
undo step. A .thmx still brings its theme only.

Reuse Slides' master copy (media, fresh master, layout and shape ids) moves
into reuse::import_master so both commands share it.

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 22db926
# Last command done (1 command done):
#    pick 8992623 #807: Browse for Themes brings a template's slide master and layouts
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/engine/src/cmd/design.rs
#	modified:   crates/engine/src/cmd/reuse.rs
#	modified:   docs/file-format-parity.md
#
2026-10-18 17:46:44 +00: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 ea98ebb615 Drop a clippy allow the workspace already sets 2026-10-18 18:47:14 +02:00
MarekSpodBiedry 85b994e7cd Hide the slide show media controls when the pointer is idle (#967) 2026-10-18 18:42:39 +02:00
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
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
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
Hamza afd6d3a3eb Header & Footer: the automatic date takes a format
`HeaderFooter::date_format` is documented as the `datetime1`…`datetime13`
format for automatic dates, and nothing read it: grep found one hit, the field's
own declaration. The Date placeholder was hardcoded to `datetime1`, so the
footer date was always the short date, and a `dateFormat` parameter was accepted
and silently dropped — the command returned Ok, so a caller could not tell.

Everything else was already there: `datetime::field_text` renders all thirteen
types and Insert ▸ Date & Time already offers them. Missing was the wiring from
a choice to the placeholder.

`design.headerFooter` now takes `dateFormat`, refuses anything outside the
thirteen rather than ignoring it, stores it, creates the placeholder with that
field type, and updates an existing one when the format changes — as it already
did for a changed `dateText`. A fixed date still wins as plain text, and going
back to automatic restores the remembered format.

`datetime::DATE_KINDS` is the canonical list; `dialogs.rs` uses it rather than
its own copy of the same thirteen strings, and the Header & Footer dialog gains
a Format dropdown listing each type by what it would show now, enabled while the
date is automatic.

Not here: reading a deck does not populate `date_format`, so the dialog shows
datetime1 preselected for a deck whose dates are something else until the user
picks. That is a pptx read-side change and wants its own PR.
2026-10-17 17:17:37 +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 a55b6cfa6f Clamp text.selectParagraph paragraph index to avoid overflow
Found while testing crates/engine/src/cmd/text.rs:1151 (select_para)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:46:04 +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
Nikita RakitinandCursor beb883d036 Files: save and open outlines as RTF
Adds PowerPoint's Outline/RTF save type: slide titles at outline level 0
(bold, larger), body paragraphs indented at their outline level, bold,
italic and underline kept, non-ASCII as \u escapes. RTF outlines (ours,
PowerPoint's or a word processor's, by outline level or indent) open as
a presentation or drop in as slides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 20:34:44 +03:00
Marco Arnulfo 5f0a088870 PPTX: paragraph builds edited in PowerPoint keep their clicks (part of #844) 2026-10-16 16:19:13 +02:00
midasmatterhorn 013941e8e8 engine: a hostile text selection can't drive paragraph commands past the body
Paragraph commands (align, case, insert, paste, selectParagraph) iterated
for pi in a.0..=b.0 with both ends taken verbatim from text.select, so a
control-channel selection with caret u64::MAX spun the loop over 2^64
entries on the UI thread, and text.selectParagraph's at.0 + 1 overflow-
panicked in debug builds. Clamp every range at the body's last paragraph
and use saturating_add for the single-index arithmetic.

Regression test: paragraph_commands_clamp_a_hostile_text_selection —
fails on main with 'attempt to add with overflow' at cmd/text.rs:1151,
passes with the clamp.
2026-10-16 15:12:10 +03:00
Marco Arnulfo d28b4e0f54 Review: reject revisions after Compare, one or all
review.reject {index | all} drops revisions from the list without applying
them, so the open deck stays as it is: a rejected inserted slide is never
added, a rejected deleted slide stays, a rejected changed slide keeps its
current content. Accept All skips what was rejected. Rejections are kept on
the compare session (keyed by the open slide id and the revised slide) and
end with End Review; like Compare and End Review, rejecting is not an undo
step, since it leaves the document untouched.

Revisions pane: Reject next to each Accept, and Reject All.
2026-10-15 22:54:04 +02:00
Marco Arnulfo 13e06cace2 Shapes: Edit Points — Straight/Curved Segment and Open/Close Path from the right-click menu 2026-10-15 20:04:58 +02:00
Marco Arnulfo b27bd0519c Preferences: checkboxes and slider rails show in light mode (fixes #766) 2026-10-15 11:53:33 +02:00
debarghaya b919e33217 UI: close About dialog on Cancel and test Options Cancel from File tab (#782)
In release 0.7.0, Cancel in the Preferences dialog (opened via Options
in the File tab) did nothing. This was previously addressed on main by
#757 (#752).

This change adds an egui_kittest regression test verifying that clicking
Options from the File tab opens the Preferences dialog and clicking
Cancel closes it while keeping Backstage open. In addition, the About
dialog's Cancel button now properly closes the dialog instead of being
ignored.

Closes #782.
2026-10-15 13:24:39 +05:30
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
debarghaya 0fea876101 Web: point web app favicon to deckcraft.ico 2026-10-15 12:06:20 +05:30
Fabian 283be17f93 fix: DeckCraft upstream #93
Keep the keyboard while editing list text: Tab and the arrow keys moved
egui's keyboard focus onto ordinary widgets, and that stray focus
swallowed every later key, so lists could not be typed into or continued
with Enter.
2026-10-13 00:38:29 +02:00
519 changed files with 70818 additions and 52402 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+43 -37
View File
@@ -1,55 +1,61 @@
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:
timeout-minutes: 60
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 }}
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
# Keep this pin identical to gate, freebsd.yml and release.yml.
font-pin:
runs-on: ubuntu-latest
env:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- 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
- name: Font pin gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
# a proposed future SHA; update it together with all three workflow pins.
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
run: |
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
if [ -f "$manifest" ]; then
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
else
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
exit 1
fi
gate:
needs: font-pin
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
libgl1-mesa-dev
+11 -6
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:
@@ -26,4 +27,8 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
# qualify the required Chinese input after the pin advances; that gate stays explicitly
# pending in docs/egui-037-migration-plan.md section 5.1.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -1
View File
@@ -24,7 +24,10 @@ jobs:
with:
app-name: deckcraft
app-title: DeckCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# 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:
+6 -3
View File
@@ -10,8 +10,9 @@ on:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
# Nightly, so dependency and runner-image drift shows up within a day.
schedule:
- cron: "41 3 * * 1"
- cron: "3 5 * * *"
workflow_dispatch:
concurrency:
@@ -23,8 +24,10 @@ permissions:
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
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: deckcraft
app-dir: DeckCraft
install-relative-path: Programs/ArtCraft/DeckCraft
install-scope: perUser
+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`.
+21 -5
View File
@@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings.
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / libvpx-vp9 / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
@@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings.
| Asset | Author | Source | Licence | Used for |
|---|---|---|---|---|
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
@@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`:
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
provides one) and BIZ UDPGothic Regular.
## PDF writer colour profiles
@@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
### Optional native Chinese fallback
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
| Family | Author | Source | Licence | Use |
|---|---|---|---|---|
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
+4
View File
@@ -3,6 +3,10 @@
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
win over anything here.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
Generated
+978 -488
View File
File diff suppressed because it is too large Load Diff
+37 -9
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.9.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -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,16 +59,20 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = { version = "0.37", default-features = false }
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -74,19 +80,26 @@ arboard = { version = "3.6", default-features = false }
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
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
@@ -95,4 +108,19 @@ codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+24 -1
View File
@@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md.
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
not in the web payload.
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
@@ -80,3 +86,20 @@ Code dependencies
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+39
View File
@@ -7,6 +7,10 @@
</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">
@@ -220,6 +224,41 @@ DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcom
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 |
+77 -1
View File
@@ -2,7 +2,13 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -131,19 +137,67 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
| 2026-10-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-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
| 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
| 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-24 | Edit Points: hold Shift, Ctrl or Alt while dragging a point's handle to make it a smooth, straight or corner point, as in PowerPoint (Cmd and Option work as Ctrl and Alt on the Mac); the point keeps that type for the next drag |
| 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-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
| 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-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
| 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 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
| 2026-10-24 | Edit Points: Esc pressed before the mouse button is released cancels a point or handle drag, as in PowerPoint, and leaves the shape as it was with no undo step. Edit Points stays on after the cancel |
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
| 2026-10-24 | Edit Points: right-click a segment for Add Point (at the pointer), Delete Segment (a closed outline opens there; grayed out on an open one) and Open/Close Path, as in PowerPoint (`shape.deleteSegment`) |
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
| 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. |
@@ -240,11 +294,33 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
| 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 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-24 | trivial | Progress log: picture and embedded object fills |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
| 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 |
+3 -3
View File
@@ -73,7 +73,7 @@ fn main() -> ExitCode {
Some("app") => report(app(rest)),
Some("mcp") => report(mcp(rest)),
Some("links") => {
use deckcraft_engine::links::*;
use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE};
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
ExitCode::SUCCESS
}
@@ -208,7 +208,7 @@ fn bench(args: &[String]) -> Result<(), String> {
("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))?),
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
];
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
return Ok(vec![out.to_string()]);
}
let p = std::path::Path::new(out);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
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();
+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;
@@ -1,6 +1,4 @@
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
// 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)]
use std::io::Write;
use std::path::PathBuf;
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>DeckCraft</title>
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+14 -9
View File
@@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
const CANVAS_ID: &str = "deckcraft_canvas";
const LOADING_ID: &str = "deckcraft_loading";
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
/// build the sample or blank deck, and replace the loading element when launch finishes.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -36,6 +38,7 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
@@ -74,11 +77,17 @@ struct WebShell {
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
// Capture the document and slide now. The bytes arrive later, after the user may
// have switched slides.
self.app.logic(ctx);
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
/// and deliver the id, name, and bytes through the inbox.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
// fresh input, and capture the document/slide before the asynchronous read finishes.
for f in std::mem::take(&mut raw.dropped_files) {
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
@@ -97,11 +106,7 @@ impl eframe::App for WebShell {
}
});
}
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+2
View File
@@ -18,12 +18,14 @@ 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.
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
+3 -2
View File
@@ -41,8 +41,9 @@ pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
if let Err(e) = app.open_path(p) {
log::error!("{p}: {e}");
app.set_status(format!("Couldn't open {p}: {e}"));
let safe_p = p.replace(['\r', '\n'], " ");
log::error!("{safe_p}: {e}");
app.set_status(format!("Couldn't open {safe_p}: {e}"));
}
}
}
+80 -203
View File
@@ -8,11 +8,11 @@
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
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;
use std::time::{Duration, Instant};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
@@ -61,16 +61,35 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
}
/// 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 longest a connection refused as busy is drained before it is closed.
const REFUSED_GRACE: Duration = Duration::from_millis(250);
/// Refused connections drained at once, each on a thread of its own. Past this many, a refused
/// connection gets the busy reply and is closed without a drain.
const MAX_REFUSING: usize = 16;
/// 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));
let refusing = Arc::new(AtomicUsize::new(0));
std::thread::spawn(move || {
for mut 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"}));
refuse(stream, &refusing, limits.write_timeout.min(REFUSED_GRACE));
continue;
}
let slot = Slot(Arc::clone(&active));
@@ -88,6 +107,27 @@ fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) ->
rx
}
/// Close a connection that was told it is refused. A peer that wrote a line before it read the
/// reply would see only a reset, so the connection is drained for up to `grace` first, on a
/// thread of its own: the accepting thread goes straight back to accepting. Past
/// [`MAX_REFUSING`] drains at once, the connection is closed without one.
fn refuse(stream: TcpStream, refusing: &Arc<AtomicUsize>, grace: Duration) {
if refusing.fetch_add(1, Ordering::SeqCst) >= MAX_REFUSING {
refusing.fetch_sub(1, Ordering::SeqCst);
return;
}
let slot = Slot(Arc::clone(refusing));
let spawned = std::thread::Builder::new().name("control-refused".into()).spawn(move || {
let _slot = slot;
if let Ok(read) = stream.try_clone() {
close_politely(&stream, &mut BufReader::new(read), grace);
}
});
if spawned.is_err() {
log::warn!("control server could not start a thread to close a refused connection");
}
}
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
struct Slot(Arc<AtomicUsize>);
@@ -170,6 +210,37 @@ fn is_http_request_line(text: &str) -> bool {
|| 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. The timeout is set on the handle that
// reads: on Windows, a read timeout set on one handle of a `try_clone` pair can fail to apply
// to reads through the other.
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() || reader.get_ref().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;
@@ -182,6 +253,7 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
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,
@@ -189,8 +261,11 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
let reply = match parse(&line) {
Parsed::Blank => continue,
Parsed::Reject(e) => {
// The connection closes either way; a peer that can't read the reason loses nothing.
// 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 } => {
@@ -232,202 +307,4 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
}
#[cfg(test)]
mod tests {
use super::*;
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 /\"}"));
}
#[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();
}
}
mod tests;
+299
View File
@@ -0,0 +1,299 @@
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() {
// A slot comes back only when the thread serving a peer ends, which can be after the next
// peer connects, so every peer has a slot of its own.
const PEERS: usize = 12;
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
for _ in 0..PEERS {
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");
}
/// A peer refused because every connection is taken still reads why when it had already
/// written a line: closing with that line unread resets the connection and discards the
/// reply. Repeated because it depends on the timing of the two.
#[test]
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
ui(rx);
// Take the only slot, and make sure it is being served.
let (mut hr, mut hw) = client(addr);
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
assert!(reply(&mut hr).is_some());
for _ in 0..50 {
let (mut r, mut w) = client(addr);
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
}
}
/// Peers refused as busy that stay connected without sending anything do not hold up the
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
#[test]
fn silent_refused_peers_do_not_delay_the_next_refusal() {
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
ui(rx);
let (mut hr, mut hw) = client(addr);
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
assert!(reply(&mut hr).is_some());
// Twenty drains of 250 ms each, one after another, would take five seconds.
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
let start = Instant::now();
let (mut r, _w) = client(addr);
let refused = reply(&mut r).unwrap();
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
drop(silent);
}
/// A peer that keeps its connection open after its line was rejected gives its slot back at
/// the drain deadline (the write timeout), not at the idle timeout.
#[test]
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
ui(rx);
let (mut r, mut w) = client(addr);
w.write_all(b"hello\n").unwrap();
assert_eq!(reply(&mut r).unwrap()["ok"], false);
// `w` stays open, sending nothing.
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));
}
drop(w);
}
#[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() {
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
// third peer connected, and that peer then took its slot instead of reading `busy`.
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..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 request that reaches the one-slot queue first fills it and times out; the
// other finds it full. Each connection has its own thread, so either request can be first.
let (mut ar, mut aw) = client(addr);
let (mut br, mut bw) = client(addr);
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
let a = reply(&mut ar).unwrap();
let b = reply(&mut br).unwrap();
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
errors.sort_unstable();
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
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();
}
+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]
+37 -2
View File
@@ -42,8 +42,9 @@ impl eframe::App for App {
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward native raw input to the wrapped application once per pass.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -180,10 +181,19 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
/// Install logging, parse startup arguments and environment, launch the native app, and
/// propagate an eframe startup failure.
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("DeckCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
@@ -255,8 +265,10 @@ fn main() -> eframe::Result {
"DeckCraft",
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {
app.session.recovery_dir = deckcraft_engine::recovery::default_dir();
@@ -418,4 +430,27 @@ mod tests {
assert_eq!(mac, win, "macOS and Windows offer different file types");
assert_eq!(mac, linux, "macOS and Linux offer different file types");
}
/// #1114: a shortcut that names an MSI `<Icon>` shows a blank icon once an update deletes the
/// cached copy of it, and a taskbar pin (a copy of the shortcut) is never repaired. Shortcuts
/// take the icon embedded in `deckcraft.exe`; only `ARPPRODUCTICON` uses the MSI `<Icon>`.
#[test]
fn windows_shortcuts_use_the_icon_embedded_in_the_exe() {
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
let shortcuts: Vec<&str> = wxs.split("<Shortcut").skip(1).filter_map(|s| s.split("/>").next()).collect();
assert!(!shortcuts.is_empty(), "deckcraft.wxs has no shortcut");
for shortcut in shortcuts {
assert!(!shortcut.contains("Icon="), "a shortcut names an MSI <Icon>:{shortcut}");
}
assert!(wxs.contains("<Property Id=\"ARPPRODUCTICON\""), "Apps and features keeps its icon");
}
/// The shortcuts take the exe's own icon, and `package.ps1 -SkipBuild` packages an existing
/// exe without `DECKCRAFT_REQUIRE_WINRES`, so the script has to refuse one with no icon.
#[test]
fn the_windows_package_script_rejects_an_exe_without_an_icon() {
let script = include_str!("../../../packaging/windows/package.ps1");
assert!(script.contains("ExtractIconEx"), "package.ps1 does not count the exe's icons");
assert!(script.contains("has no embedded icon"), "package.ps1 does not reject an iconless exe");
}
}
+4
View File
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+9
View File
@@ -59,3 +59,12 @@ real_name = "Ryan C. Shoemaker"
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.GhostMitra]
sync_github_name = true
[people.Katacc]
sync_github_name = true
[people.SofiaAFigueredo]
real_name = "Sofia Alves Figueredo"
+1 -1
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;
+21 -21
View File
@@ -1,10 +1,10 @@
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
//! and automatic advance.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use deckcraft_model::Presentation;
use deckcraft_model::text::{Action, Hyperlink};
use deckcraft_model::{Presentation, SlideId};
use crate::Timeline;
use crate::interact::SlideRun;
@@ -29,18 +29,8 @@ pub enum ShowAction {
Exit,
}
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
/// opened on a click.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
// Which links the show opens on a click; PDF export writes the same ones.
pub use deckcraft_model::text::safe_url;
/// What a clicked link does in the show (`ShowState::follow`).
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -83,12 +73,25 @@ pub struct ShowState {
pub zoom: Option<(usize, usize)>,
}
/// Index of each slide by id (the first slide wins a duplicate id), so resolving a custom show
/// costs the sum of its two lists, not their product.
fn slide_indices(pres: &Presentation) -> HashMap<SlideId, usize> {
let mut map = HashMap::with_capacity(pres.slides.len());
for (i, s) in pres.slides.iter().enumerate() {
map.entry(s.id).or_insert(i);
}
map
}
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
/// hidden slides.
pub fn show_order(pres: &Presentation) -> Vec<usize> {
let n = pres.slides.len();
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
Some(cs) => {
let index = slide_indices(pres);
cs.slides.iter().filter_map(|id| index.get(id).copied()).collect()
}
None => match pres.show.range {
Some((a, b)) => {
let a = (a.max(1) as usize).saturating_sub(1);
@@ -104,12 +107,9 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
/// 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();
let index = slide_indices(pres);
let order: Vec<usize> =
cs.slides.iter().filter_map(|id| index.get(id).copied()).filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect();
if order.is_empty() { None } else { Some(order) }
}
+84 -1
View File
@@ -7,7 +7,10 @@ use deckcraft_model::{
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
};
use crate::*;
use crate::{
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
};
const W: f64 = 960.0;
const H: f64 = 540.0;
@@ -344,6 +347,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(
@@ -1052,3 +1085,53 @@ fn show_returns_to_the_zoom_slide() {
s.goto(&p, 4);
assert_eq!(s.next(&p), ShowAction::Slide(5));
}
#[test]
fn custom_show_resolves_duplicate_ids_to_the_first_slide() {
let mut p = pres(3);
Arc::make_mut(&mut p.slides[2]).id = p.slides[0].id;
p.custom_shows = vec![CustomShow { name: "Dup".into(), slides: vec![p.slides[0].id, SlideId(1001), SlideId(424_242)] }];
p.show.custom_show = Some("Dup".into());
assert_eq!(show_order(&p), vec![0, 1]);
}
#[test]
fn custom_show_with_many_entries_resolves_quickly() {
let mut p = pres(20_000);
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
p.custom_shows = vec![CustomShow { name: "All".into(), slides: ids.iter().rev().copied().collect() }];
p.show.custom_show = Some("All".into());
let t = std::time::Instant::now();
let order = show_order(&p);
assert_eq!(order.len(), 20_000);
assert_eq!(order[0], 19_999);
assert!(t.elapsed().as_secs() < 5);
}
#[test]
fn timeline_targets_the_first_shape_with_a_duplicate_id() {
let mut s = slide_with(vec![anim(7, AnimClass::Entrance, "fade", AnimStart::OnClick)]);
s.shapes[0] = shape(7, "first");
s.shapes[1] = shape(7, "second");
s.shapes[0].xfrm = Some(Xfrm::new(10.0, 10.0, 20.0, 20.0));
let tl = Timeline::new(&s, W, H);
assert_eq!(tl.effects().len(), 1);
assert_eq!(tl.steps(), 1);
}
#[test]
fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
let mut s = slide_with(vec![]);
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
s.animations = (0..2_000)
.map(|_| {
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
a.text_build = TextBuild::ByParagraph;
a.path = Some("M 0 0 L 0.5 0.5 E".into());
a
})
.collect();
let tl = Timeline::new(&s, W, H);
assert_eq!(tl.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
}
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use deckcraft_model::text::Action;
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
use crate::*;
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
fn shape(id: u32) -> Shape {
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
+72 -27
View File
@@ -1,10 +1,11 @@
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::anim::TextBuild;
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
use crate::AnimState;
use crate::effects::{EffectSpec, exit_base, motion_path};
@@ -12,7 +13,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
pub const PARAGRAPH_STAGGER: f64 = 0.5;
const MAX_EFFECTS: usize = 100_000;
const MAX_PARAGRAPHS: usize = 1_000;
const MAX_ITERATIONS: f64 = 1.0e6;
/// Which sequence an effect belongs to.
@@ -86,7 +86,9 @@ enum After {
#[derive(Clone, Debug)]
struct Effect {
timing: EffectTiming,
spec: EffectSpec,
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
/// per paragraph.
spec: Arc<EffectSpec>,
after: After,
}
@@ -163,6 +165,14 @@ impl Timeline {
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut tl = Timeline::default();
if slide.animations.is_empty() {
return tl;
}
// Every shape by id, built once: resolving each animation's target by tree search would
// cost shapes x animations.
let shapes = shape_index(&slide.shapes);
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
let mut para_cache: HashMap<ShapeId, Arc<Vec<Option<usize>>>> = HashMap::new();
// 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())];
@@ -191,34 +201,38 @@ impl Timeline {
if tl.effects.len() >= MAX_EFFECTS {
break;
}
let shape = slide.shape(a.shape);
let shape = shapes.get(&a.shape).copied();
// Paragraph targets of this animation.
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
(Some(p), _) => vec![Some(p as usize)],
let paras: Arc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
(Some(p), _) => Arc::new(vec![Some(p as usize)]),
(None, TextBuild::ByParagraph) => {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut owner: Option<usize> = None;
for (i, p) in t.paragraphs.iter().enumerate() {
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
&& grouped.insert((a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
let cached = para_cache.entry(a.shape).or_insert_with(|| {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
match role {
BuildRole::Top => v.push(Some(i)),
BuildRole::Member(o) => {
if grouped.insert((a.shape, i, o)) {
tl.groups.push((a.shape, i, o));
}
}
BuildRole::Unowned => {}
}
}
}
}
if v.is_empty() { vec![None] } else { v }
if v.is_empty() {
v.push(None)
}
Arc::new(v)
});
Arc::clone(cached)
}
_ => vec![None],
_ => Arc::new(vec![None]),
};
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
let xf = sanitize_xfrm(xf, sw, sh);
let spec = build_spec(a, xf, sw, sh, &resolve);
let spec = Arc::new(build_spec(a, xf, sw, sh, &resolve));
let dur = secs(a.duration_ms);
let delay = secs(a.delay_ms);
let until_next_click = a.repeat == u32::MAX;
@@ -287,7 +301,7 @@ impl Timeline {
span,
class: a.class,
},
spec: spec.clone(),
spec: Arc::clone(&spec),
after: after.clone(),
});
}
@@ -298,12 +312,13 @@ impl Timeline {
}
}
// Base colours of animated shapes.
let mut colored: HashSet<ShapeId> = HashSet::new();
for e in &tl.effects {
let id = e.timing.shape;
if tl.colors.iter().any(|(s, _)| *s == id) {
if !colored.insert(id) {
continue;
}
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
Some(Fill::Solid { color }) => resolve(color),
_ => accent,
};
@@ -359,6 +374,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()
@@ -523,6 +551,23 @@ fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
Status::Active(p.clamp(0.0, 1.0))
}
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
/// 64 deep); the first shape with an id wins a duplicate.
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
if depth > 64 {
return;
}
for s in shapes {
out.entry(s.id).or_insert(s);
walk(s.children(), depth + 1, out);
}
}
let mut out = HashMap::new();
walk(shapes, 0, &mut out);
out
}
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
+4 -1
View File
@@ -9,7 +9,10 @@ use std::f64::consts::PI;
use crate::easing::ease;
use crate::transition::{FULL, Layer, R, Source};
use crate::transition_geom::*;
use crate::transition_geom::{
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
};
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
+118 -77
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
@@ -45,7 +45,7 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
let var_str = if (var >> 6) != 0 { (31 - ((var >> 6) as u32).leading_zeros()).min(12) as i32 } else { 0 };
pri = if var != 0 { (pri * (4 + var_str) + 8) >> 4 } else { 0 };
let damping = cp.damping as i32 + coeff_shift as i32;
filter(fs, out, 0, r, c, pri, sec, damping, dir);
filter(fs, out, 0, (r, c), (pri, sec), damping, dir);
if fs.num_planes == 1 {
return true;
}
@@ -53,8 +53,8 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
let sec = (cp.uv_sec[idx] << coeff_shift) as i32;
let dir = if pri == 0 { 0 } else { CDEF_UV_DIR[fs.ssx][fs.ssy][y_dir] as usize };
let damping = cp.damping as i32 + coeff_shift as i32 - 1;
filter(fs, out, 1, r, c, pri, sec, damping, dir);
filter(fs, out, 2, r, c, pri, sec, damping, dir);
filter(fs, out, 1, (r, c), (pri, sec), damping, dir);
filter(fs, out, 2, (r, c), (pri, sec), damping, dir);
true
}
@@ -124,33 +124,74 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
if diff < 0 { -mag } else { mag }
}
#[allow(clippy::too_many_arguments)]
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
/// Tap offsets, weights and strengths of one block.
struct Taps {
pri: i32,
sec: i32,
pri_adj: u32,
sec_adj: u32,
pri_off: [isize; 2],
sec_off: [[isize; 2]; 2],
pri_taps: [i32; 2],
sec_taps: [i32; 2],
}
impl Taps {
fn new(coeff_shift: u32, (pri, sec): (i32, i32), damping: i32, dir: usize) -> Taps {
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
let pri_off = [off(dir, 0), off(dir, 1)];
let d_lo = (dir + 6) & 7;
let d_hi = (dir + 2) & 7;
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
Taps { pri, sec, pri_adj: adj(pri), sec_adj: adj(sec), pri_off, sec_off, pri_taps, sec_taps }
}
let coeff_shift = fs.bit_depth - 8;
/// The 12 (offset, weight, strength, adjustment) taps in filtering order.
fn tap_list(&self) -> [(isize, i32, i32, u32); 12] {
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
let mut n = 0;
for k in 0..2 {
for sign in [-1isize, 1] {
taps[n] = (sign * self.pri_off[k], self.pri_taps[k], self.pri, self.pri_adj);
taps[n + 1] = (sign * self.sec_off[k][0], self.sec_taps[k], self.sec, self.sec_adj);
taps[n + 2] = (sign * self.sec_off[k][1], self.sec_taps[k], self.sec, self.sec_adj);
n += 3;
}
}
taps
}
}
/// Position and size of a block within its plane.
#[derive(Clone, Copy)]
struct BlockRect {
x0: usize,
y0: usize,
w: usize,
h: usize,
}
/// The block with two samples of padding on every side, plus whether every sample is available
/// (CdefAvailable: its 4x4 block is inside the frame).
fn padded_block(fs: &FrameState, plane: usize, src: &Plane, rect: BlockRect) -> ([i32; PAD_STRIDE * PAD_STRIDE], bool) {
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
let x0 = (c * 4) >> sub_x;
let y0 = (r * 4) >> sub_y;
let w = 8 >> sub_x;
let h = 8 >> sub_y;
let src = &fs.cur.planes[plane];
let (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
// CdefAvailable per sample: its 4x4 block is inside the frame.
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
let mut all_avail = true;
for i in 0..h + 4 {
let y = (y0 + i) as isize - 2;
for i in 0..rect.h + 4 {
let y = (rect.y0 + i) as isize - 2;
let cand_r = (y << sub_y) >> 2;
if y < 0 || cand_r >= mi_rows {
all_avail = false;
continue;
}
let row = src.row(y as usize);
for j in 0..w + 4 {
let x = (x0 + j) as isize - 2;
for j in 0..rect.w + 4 {
let x = (rect.x0 + j) as isize - 2;
let cand_c = (x << sub_x) >> 2;
if x >= 0 && cand_c < mi_cols {
buf[i * PAD_STRIDE + j] = row[x as usize] as i32;
@@ -159,70 +200,70 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
}
}
}
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
let pri_off = [off(dir, 0), off(dir, 1)];
let d_lo = (dir + 6) & 7;
let d_hi = (dir + 2) & 7;
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
(buf, all_avail)
}
/// Filters one sample whose taps may be unavailable (CDEF_NA).
fn filter_sample(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &Taps, ci: isize, x: i32) -> u16 {
let mut sum = 0i32;
let mut max = x;
let mut min = x;
for k in 0..2 {
for sign in [-1isize, 1] {
let p = buf[(ci + sign * taps.pri_off[k]) as usize];
if p != CDEF_NA {
if taps.pri != 0 {
sum += taps.pri_taps[k] * constrain(p - x, taps.pri, taps.pri_adj);
}
max = max.max(p);
min = min.min(p);
}
for s in 0..2 {
let q = buf[(ci + sign * taps.sec_off[k][s]) as usize];
if q != CDEF_NA {
if taps.sec != 0 {
sum += taps.sec_taps[k] * constrain(q - x, taps.sec, taps.sec_adj);
}
max = max.max(q);
min = min.min(q);
}
}
}
}
(x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16
}
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, (r, c): (usize, usize), strengths: (i32, i32), damping: i32, dir: usize) {
let (pri, sec) = strengths;
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
}
let coeff_shift = fs.bit_depth - 8;
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
let rect = BlockRect { x0: (c * 4) >> sub_x, y0: (r * 4) >> sub_y, w: 8 >> sub_x, h: 8 >> sub_y };
let src = &fs.cur.planes[plane];
let (buf, all_avail) = padded_block(fs, plane, src, rect);
let taps = Taps::new(coeff_shift, strengths, damping, dir);
let dst = &mut out.planes[plane];
if all_avail {
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
let mut n = 0;
for k in 0..2 {
for sign in [-1isize, 1] {
taps[n] = (sign * pri_off[k], pri_taps[k], pri, pri_adj);
taps[n + 1] = (sign * sec_off[k][0], sec_taps[k], sec, sec_adj);
taps[n + 2] = (sign * sec_off[k][1], sec_taps[k], sec, sec_adj);
n += 3;
}
}
let rows = (y0, x0, h);
match w {
8 => filter_rows::<8>(&buf, &taps, dst, rows),
_ => filter_rows::<4>(&buf, &taps, dst, rows),
let list = taps.tap_list();
let rows = (rect.y0, rect.x0, rect.h);
match rect.w {
8 => filter_rows::<8>(&buf, &list, dst, rows),
_ => filter_rows::<4>(&buf, &list, dst, rows),
}
return;
}
for i in 0..h {
for i in 0..rect.h {
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
// MiRows / MiCols edge).
let srow = &src.row(y0 + i)[x0..x0 + w];
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + w];
for j in 0..w {
let srow = &src.row(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
let drow = &mut dst.row_mut(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
for j in 0..rect.w {
let ci = ((i + 2) * PAD_STRIDE + j + 2) as isize;
let x = srow[j] as i32;
let mut sum = 0i32;
let mut max = x;
let mut min = x;
for k in 0..2 {
for sign in [-1isize, 1] {
let p = buf[(ci + sign * pri_off[k]) as usize];
if p != CDEF_NA {
if pri != 0 {
sum += pri_taps[k] * constrain(p - x, pri, pri_adj);
}
max = max.max(p);
min = min.min(p);
}
for s in 0..2 {
let q = buf[(ci + sign * sec_off[k][s]) as usize];
if q != CDEF_NA {
if sec != 0 {
sum += sec_taps[k] * constrain(q - x, sec, sec_adj);
}
max = max.max(q);
min = min.min(q);
}
}
}
}
drow[j] = (x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16;
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
}
}
}
@@ -242,14 +283,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.
+88 -58
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);
}
}
}
@@ -401,49 +405,35 @@ impl Decoder {
r
}
/// Whether an OBU belongs to the selected operating point (spec 7.5: OBUs of other layers are dropped).
fn in_operating_point(&self, obu: &ObuUnit) -> bool {
if obu.obu_type != OBU_SEQUENCE_HEADER_T
&& obu.obu_type != OBU_TEMPORAL_DELIMITER_T
&& obu.ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> obu.temporal_id) & 1;
let in_s = (idc >> (obu.spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
return false;
}
}
true
}
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
let mut pos = 0;
while pos < data.len() {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
let obu = parse_obu_unit(data, pos)?;
pos = obu.end;
if !self.in_operating_point(&obu) {
continue;
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
let payload = &data[p..end];
pos = end;
if obu_type != OBU_SEQUENCE_HEADER_T
&& obu_type != OBU_TEMPORAL_DELIMITER_T
&& ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> temporal_id) & 1;
let in_s = (idc >> (spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
continue;
}
}
match obu_type {
let (payload, spatial_id) = (obu.payload, obu.spatial_id);
match obu.obu_type {
OBU_SEQUENCE_HEADER_T => {
let s = SequenceHeader::parse(payload)?;
self.seq = Some(s);
@@ -453,24 +443,11 @@ impl Decoder {
tu += 1;
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if self.seen_frame_header {
// frame_header_copy(): identical to the active header
if obu_type != OBU_FRAME_T {
continue;
if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
}
let mut r = BitReader::new(payload);
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
if let Some(o) = shown {
out.push((o, tu, spatial_id));
continue;
}
if obu_type == OBU_FRAME_T {
r.byte_align();
let rest = payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
if let Some(o) = self.tile_group(rest)? {
out.push((o, tu, spatial_id));
}
}
}
OBU_TILE_GROUP_T => {
@@ -485,6 +462,22 @@ impl Decoder {
Ok(())
}
/// A frame header or frame OBU: returns the picture it completes, if any.
fn frame_obu(&mut self, obu: &ObuUnit) -> Result<Option<Output>> {
// frame_header_copy(): identical to the active header
if self.seen_frame_header && obu.obu_type != OBU_FRAME_T {
return Ok(None);
}
let mut r = BitReader::new(obu.payload);
let shown = self.frame_header(&mut r, obu.temporal_id, obu.spatial_id)?;
if shown.is_some() || obu.obu_type != OBU_FRAME_T {
return Ok(shown);
}
r.byte_align();
let rest = obu.payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
self.tile_group(rest)
}
/// frame_header_obu( ): returns the output for show_existing_frame.
fn frame_header(&mut self, r: &mut BitReader, temporal_id: u32, spatial_id: u32) -> Result<Option<Output>> {
let seq = self.seq.clone().ok_or(Error::Invalid("frame before sequence header"))?;
@@ -1010,6 +1003,43 @@ mod tests {
}
}
/// One OBU: its header fields, payload and the position just past it.
struct ObuUnit<'a> {
obu_type: u32,
ext: bool,
temporal_id: u32,
spatial_id: u32,
payload: &'a [u8],
end: usize,
}
fn parse_obu_unit(data: &[u8], pos: usize) -> Result<ObuUnit<'_>> {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
Ok(ObuUnit { obu_type, ext, temporal_id, spatial_id, payload: &data[p..end], end })
}
#[cfg(test)]
mod guard_tests {
use super::*;
+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);
}
+313 -167
View File
@@ -55,21 +55,79 @@ impl Rng {
}
}
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
let _ = seq;
let bd = pic.bit_depth as u32;
let mono = pic.mono_chrome;
let num_planes = if mono { 1 } else { 3 };
let (sub_x, sub_y) = (pic.subsampling_x as usize, pic.subsampling_y as usize);
let w = pic.width as usize;
let h = pic.height as usize;
let grain_center = 128i32 << (bd - 8);
let grain_min = -grain_center;
let grain_max = (256i32 << (bd - 8)) - 1 - grain_center;
/// Picture-level parameters shared by the grain synthesis stages.
struct GrainCtx<'a> {
g: &'a FilmGrainParams,
bd: u32,
mono: bool,
sub_x: usize,
sub_y: usize,
w: usize,
h: usize,
grain_min: i32,
grain_max: i32,
}
// Generate grain (7.18.3.3).
let mut rng = Rng(g.grain_seed);
let shift = 12 - bd + g.grain_scale_shift;
impl GrainCtx<'_> {
fn num_planes(&self) -> usize {
if self.mono { 1 } else { 3 }
}
fn grain_shift(&self) -> u32 {
12 - self.bd + self.g.grain_scale_shift
}
fn ar_shift(&self) -> u32 {
self.g.ar_coeff_shift_minus_6 + 6
}
fn lag(&self) -> i32 {
self.g.ar_coeff_lag as i32
}
fn stripe_w(&self) -> usize {
self.w + 64
}
}
type GrainTemplate = Vec<[i32; 82]>;
/// The luma, Cb and Cr grain templates (7.18.3.3).
struct Templates {
luma: GrainTemplate,
cb: GrainTemplate,
cr: GrainTemplate,
}
impl Templates {
fn plane(&self, plane: usize) -> &GrainTemplate {
match plane {
0 => &self.luma,
1 => &self.cb,
_ => &self.cr,
}
}
}
fn luma_ar_sum(g: &FilmGrainParams, luma: &[[i32; 82]], lag: i32, y: usize, x: usize) -> i32 {
let mut s = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
if dr == 0 && dc == 0 {
break 'outer;
}
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
pos += 1;
}
}
s
}
fn luma_grain(c: &GrainCtx, rng: &mut Rng) -> GrainTemplate {
let g = c.g;
let shift = c.grain_shift();
let mut luma = vec![[0i32; 82]; 73];
for row in luma.iter_mut() {
for v in row.iter_mut() {
@@ -77,77 +135,89 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
*v = round2(gv, shift);
}
}
let ar_shift = g.ar_coeff_shift_minus_6 + 6;
let lag = g.ar_coeff_lag as i32;
let ar_shift = c.ar_shift();
let lag = c.lag();
for y in 3..73 {
for x in 3..82 - 3 {
let mut s = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
if dr == 0 && dc == 0 {
break 'outer;
}
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
pos += 1;
}
}
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(grain_min, grain_max);
}
}
let chroma_w = if sub_x != 0 { 44 } else { 82 };
let chroma_h = if sub_y != 0 { 38 } else { 73 };
let mut cb = vec![[0i32; 82]; 73];
let mut cr = vec![[0i32; 82]; 73];
if !mono {
for (arr, seed, on) in [(&mut cb, 0xb524u32, g.num_cb_points > 0), (&mut cr, 0x49d8, g.num_cr_points > 0)] {
rng.0 = g.grain_seed ^ seed;
for row in arr.iter_mut().take(chroma_h) {
for v in row.iter_mut().take(chroma_w) {
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
*v = round2(gv, shift);
}
}
}
for y in 3..chroma_h {
for x in 3..chroma_w - 3 {
let mut s0 = 0;
let mut s1 = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
if dr == 0 && dc == 0 {
if g.num_y_points > 0 {
let mut l = 0;
let lx = ((x - 3) << sub_x) + 3;
let ly = ((y - 3) << sub_y) + 3;
for i in 0..=sub_y {
for j in 0..=sub_x {
l += luma[ly + i][lx + j];
}
}
let l = round2(l, (sub_x + sub_y) as u32);
s0 += l * c0;
s1 += l * c1;
}
break 'outer;
}
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
s0 += cb[yy][xx] * c0;
s1 += cr[yy][xx] * c1;
pos += 1;
}
}
cb[y][x] = (cb[y][x] + round2(s0, ar_shift)).clamp(grain_min, grain_max);
cr[y][x] = (cr[y][x] + round2(s1, ar_shift)).clamp(grain_min, grain_max);
}
let s = luma_ar_sum(g, &luma, lag, y, x);
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(c.grain_min, c.grain_max);
}
}
luma
}
// Scaling lookup (7.18.3.4).
/// The luma contribution to the chroma autoregressive filter at chroma position (y, x).
fn chroma_luma_term(c: &GrainCtx, luma: &[[i32; 82]], y: usize, x: usize) -> i32 {
let mut l = 0;
let lx = ((x - 3) << c.sub_x) + 3;
let ly = ((y - 3) << c.sub_y) + 3;
for i in 0..=c.sub_y {
for j in 0..=c.sub_x {
l += luma[ly + i][lx + j];
}
}
round2(l, (c.sub_x + c.sub_y) as u32)
}
fn chroma_ar_sums(c: &GrainCtx, t: &Templates, y: usize, x: usize) -> (i32, i32) {
let g = c.g;
let lag = c.lag();
let mut s0 = 0;
let mut s1 = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
if dr == 0 && dc == 0 {
if g.num_y_points > 0 {
let l = chroma_luma_term(c, &t.luma, y, x);
s0 += l * c0;
s1 += l * c1;
}
break 'outer;
}
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
s0 += t.cb[yy][xx] * c0;
s1 += t.cr[yy][xx] * c1;
pos += 1;
}
}
(s0, s1)
}
/// Chroma grain templates; zero for monochrome pictures.
fn chroma_grain(c: &GrainCtx, rng: &mut Rng, luma: GrainTemplate) -> Templates {
let g = c.g;
let mut t = Templates { luma, cb: vec![[0i32; 82]; 73], cr: vec![[0i32; 82]; 73] };
if c.mono {
return t;
}
let shift = c.grain_shift();
let ar_shift = c.ar_shift();
let chroma_w = if c.sub_x != 0 { 44 } else { 82 };
let chroma_h = if c.sub_y != 0 { 38 } else { 73 };
for (arr, seed, on) in [(&mut t.cb, 0xb524u32, g.num_cb_points > 0), (&mut t.cr, 0x49d8, g.num_cr_points > 0)] {
rng.0 = g.grain_seed ^ seed;
for row in arr.iter_mut().take(chroma_h) {
for v in row.iter_mut().take(chroma_w) {
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
*v = round2(gv, shift);
}
}
}
for y in 3..chroma_h {
for x in 3..chroma_w - 3 {
let (s0, s1) = chroma_ar_sums(c, &t, y, x);
t.cb[y][x] = (t.cb[y][x] + round2(s0, ar_shift)).clamp(c.grain_min, c.grain_max);
t.cr[y][x] = (t.cr[y][x] + round2(s1, ar_shift)).clamp(c.grain_min, c.grain_max);
}
}
t
}
/// Scaling lookup tables (7.18.3.4), one per plane.
fn scaling_luts(g: &FilmGrainParams, num_planes: usize) -> [[i32; 256]; 3] {
let mut lut = [[0i32; 256]; 3];
for (plane, l) in lut.iter_mut().enumerate().take(num_planes) {
let (n, xs, ys) = if plane == 0 || g.chroma_scaling_from_luma {
@@ -159,101 +229,141 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
};
*l = scaling_lut(n, xs, ys);
}
let scale_lut = |plane: usize, index: i32| -> i32 {
let sh = bd - 8;
let x = index >> sh;
let rem = index - (x << sh);
if bd == 8 || x == 255 {
lut[plane][x as usize]
} else {
let start = lut[plane][x as usize];
let end = lut[plane][x as usize + 1];
start + round2((end - start) * rem, sh)
}
};
lut
}
// Noise stripes (7.18.3.5).
let stripe_w = w + 64;
let n_stripes = (h.div_ceil(2)).div_ceil(16).max(1);
let mut stripes: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
let overlap = g.overlap_flag;
for (luma_num, stripe) in stripes.iter_mut().enumerate() {
let mut r = g.grain_seed;
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
r ^= ((luma_num * 173 + 105) & 255) as u32;
rng.0 = r;
let mut x = 0;
while x < w.div_ceil(2) {
let rand = rng.next(8) as usize;
let off_x = rand >> 4;
let off_y = rand & 15;
for (plane, s) in stripe.iter_mut().enumerate().take(num_planes) {
let psx = if plane > 0 { sub_x } else { 0 };
let psy = if plane > 0 { sub_y } else { 0 };
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
let src = match plane {
0 => &luma,
1 => &cb,
_ => &cr,
fn scale_lookup(lut: &[[i32; 256]; 3], bd: u32, plane: usize, index: i32) -> i32 {
let sh = bd - 8;
let x = index >> sh;
let rem = index - (x << sh);
if bd == 8 || x == 255 {
lut[plane][x as usize]
} else {
let start = lut[plane][x as usize];
let end = lut[plane][x as usize + 1];
start + round2((end - start) * rem, sh)
}
}
/// Fills one plane of a noise stripe from the grain template at stripe column `x`.
fn fill_stripe_plane(c: &GrainCtx, src: &GrainTemplate, s: &mut [i32], plane: usize, offsets: (usize, usize), x: usize) {
let (off_x, off_y) = offsets;
let stripe_w = c.stripe_w();
let overlap = c.g.overlap_flag;
let psx = if plane > 0 { c.sub_x } else { 0 };
let psy = if plane > 0 { c.sub_y } else { 0 };
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
for i in 0..(34 >> psy) {
for j in 0..(34 >> psx) {
let mut gv = src[poy + i][pox + j];
let (idx, blend) = if psx == 0 { (i * stripe_w + x * 2 + j, j < 2) } else { (i * stripe_w + x + j, j == 0) };
if blend && overlap && x > 0 {
let old = s[idx];
gv = if psx != 0 {
old * 23 + gv * 22
} else if j == 0 {
old * 27 + gv * 17
} else {
old * 17 + gv * 27
};
for i in 0..(34 >> psy) {
for j in 0..(34 >> psx) {
let mut gv = src[poy + i][pox + j];
if psx == 0 {
let idx = i * stripe_w + x * 2 + j;
if j < 2 && overlap && x > 0 {
let old = s[idx];
gv = if j == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
gv = round2(gv, 5).clamp(grain_min, grain_max);
}
s[idx] = gv;
} else {
let idx = i * stripe_w + x + j;
if j == 0 && overlap && x > 0 {
let old = s[idx];
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
}
s[idx] = gv;
}
}
}
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
}
x += 16;
s[idx] = gv;
}
}
let noise_at = |plane: usize, y: usize, x: usize| -> i32 {
let psy = if plane > 0 { sub_y } else { 0 };
}
/// Noise stripes (7.18.3.5).
struct Stripes {
data: Vec<[Vec<i32>; 3]>,
stripe_w: usize,
overlap: bool,
sub_y: usize,
grain_min: i32,
grain_max: i32,
}
impl Stripes {
fn build(c: &GrainCtx, t: &Templates, rng: &mut Rng) -> Stripes {
let g = c.g;
let stripe_w = c.stripe_w();
let n_stripes = (c.h.div_ceil(2)).div_ceil(16).max(1);
let mut data: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
for (luma_num, stripe) in data.iter_mut().enumerate() {
let mut r = g.grain_seed;
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
r ^= ((luma_num * 173 + 105) & 255) as u32;
rng.0 = r;
let mut x = 0;
while x < c.w.div_ceil(2) {
let rand = rng.next(8) as usize;
let offsets = (rand >> 4, rand & 15);
for (plane, s) in stripe.iter_mut().enumerate().take(c.num_planes()) {
fill_stripe_plane(c, t.plane(plane), s, plane, offsets, x);
}
x += 16;
}
}
Stripes { data, stripe_w, overlap: g.overlap_flag, sub_y: c.sub_y, grain_min: c.grain_min, grain_max: c.grain_max }
}
fn noise_at(&self, plane: usize, y: usize, x: usize) -> i32 {
let psy = if plane > 0 { self.sub_y } else { 0 };
let luma_num = y >> (5 - psy);
let i = y - (luma_num << (5 - psy));
let mut gv = stripes[luma_num][plane][i * stripe_w + x];
let mut gv = self.data[luma_num][plane][i * self.stripe_w + x];
if psy == 0 {
if i < 2 && luma_num > 0 && overlap {
let old = stripes[luma_num - 1][plane][(i + 32) * stripe_w + x];
if i < 2 && luma_num > 0 && self.overlap {
let old = self.data[luma_num - 1][plane][(i + 32) * self.stripe_w + x];
gv = if i == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
gv = round2(gv, 5).clamp(grain_min, grain_max);
gv = round2(gv, 5).clamp(self.grain_min, self.grain_max);
}
} else if i < 1 && luma_num > 0 && overlap {
let old = stripes[luma_num - 1][plane][(i + 16) * stripe_w + x];
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
} else if i < 1 && luma_num > 0 && self.overlap {
let old = self.data[luma_num - 1][plane][(i + 16) * self.stripe_w + x];
gv = round2(old * 23 + gv * 22, 5).clamp(self.grain_min, self.grain_max);
}
gv
};
}
}
// Blend (7.18.3.5).
let (min_value, max_luma, max_chroma) = if g.clip_to_restricted_range {
let ml = 235 << (bd - 8);
(16 << (bd - 8), ml, if pic.matrix_coefficients as usize == MC_IDENTITY { ml } else { 240 << (bd - 8) })
} else {
let m = (256 << (bd - 8)) - 1;
(0, m, m)
};
let pix_max = (1i32 << bd) - 1;
let scaling_shift = g.grain_scaling_minus_8 + 8;
if !mono {
/// Output clipping range and scaling shift for the blend step (7.18.3.5).
struct BlendRange {
min_value: i32,
max_luma: i32,
max_chroma: i32,
pix_max: i32,
scaling_shift: u32,
}
impl BlendRange {
fn new(c: &GrainCtx, matrix_coefficients: usize) -> BlendRange {
let bd = c.bd;
let (min_value, max_luma, max_chroma) = if c.g.clip_to_restricted_range {
let ml = 235 << (bd - 8);
(16 << (bd - 8), ml, if matrix_coefficients == MC_IDENTITY { ml } else { 240 << (bd - 8) })
} else {
let m = (256 << (bd - 8)) - 1;
(0, m, m)
};
BlendRange { min_value, max_luma, max_chroma, pix_max: (1i32 << bd) - 1, scaling_shift: c.g.grain_scaling_minus_8 + 8 }
}
}
struct BlendEnv<'a> {
c: &'a GrainCtx<'a>,
lut: &'a [[i32; 256]; 3],
stripes: &'a Stripes,
range: &'a BlendRange,
}
impl BlendEnv<'_> {
fn blend_chroma(&self, py: &[u16], pu: &mut [u16], pv: &mut [u16]) {
let c = self.c;
let g = c.g;
let (w, h, sub_x, sub_y, bd) = (c.w, c.h, c.sub_x, c.sub_y, c.bd);
let cw = (w + sub_x) >> sub_x;
let ch = (h + sub_y) >> sub_y;
let [py, pu, pv] = &mut pic.planes;
for y in 0..ch {
for x in 0..cw {
let lx = x << sub_x;
@@ -272,26 +382,62 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
avg
} else {
let combined = avg * (luma_mult as i32 - 128) + orig * (mult as i32 - 128);
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, pix_max)
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, self.range.pix_max)
};
let noise = round2(scale_lut(plane, merged) * noise_at(plane, y, x), scaling_shift);
out[y * cw + x] = (orig + noise).clamp(min_value, max_chroma) as u16;
let noise = round2(scale_lookup(self.lut, bd, plane, merged) * self.stripes.noise_at(plane, y, x), self.range.scaling_shift);
out[y * cw + x] = (orig + noise).clamp(self.range.min_value, self.range.max_chroma) as u16;
}
}
}
}
if g.num_y_points > 0 {
let py = &mut pic.planes[0];
fn blend_luma(&self, py: &mut [u16]) {
let (w, h) = (self.c.w, self.c.h);
for y in 0..h {
for x in 0..w {
let orig = py[y * w + x] as i32;
let noise = round2(scale_lut(0, orig) * noise_at(0, y, x), scaling_shift);
py[y * w + x] = (orig + noise).clamp(min_value, max_luma) as u16;
let noise = round2(scale_lookup(self.lut, self.c.bd, 0, orig) * self.stripes.noise_at(0, y, x), self.range.scaling_shift);
py[y * w + x] = (orig + noise).clamp(self.range.min_value, self.range.max_luma) as u16;
}
}
}
}
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
let _ = seq;
let bd = pic.bit_depth as u32;
let grain_center = 128i32 << (bd - 8);
let c = GrainCtx {
g,
bd,
mono: pic.mono_chrome,
sub_x: pic.subsampling_x as usize,
sub_y: pic.subsampling_y as usize,
w: pic.width as usize,
h: pic.height as usize,
grain_min: -grain_center,
grain_max: (256i32 << (bd - 8)) - 1 - grain_center,
};
// Generate grain (7.18.3.3).
let mut rng = Rng(g.grain_seed);
let luma = luma_grain(&c, &mut rng);
let templates = chroma_grain(&c, &mut rng, luma);
let lut = scaling_luts(g, c.num_planes());
let stripes = Stripes::build(&c, &templates, &mut rng);
// Blend (7.18.3.5).
let range = BlendRange::new(&c, pic.matrix_coefficients as usize);
let env = BlendEnv { c: &c, lut: &lut, stripes: &stripes, range: &range };
if !c.mono {
let [py, pu, pv] = &mut pic.planes;
env.blend_chroma(py, pu, pv);
}
if g.num_y_points > 0 {
env.blend_luma(&mut pic.planes[0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
+232 -179
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;
@@ -422,6 +429,38 @@ pub struct FrameHeader {
pub spatial_id: u32,
}
/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one.
fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) {
let diff_len = st.seq.delta_frame_id_length_minus_2 + 2;
for rs in st.refs.iter_mut() {
if cur > (1 << diff_len) {
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
rs.valid = false;
}
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
rs.valid = false;
}
}
}
/// Skips buffer_removal_time for every operating point that includes this layer.
fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> {
if !(seq.decoder_model_info_present && r.flag()?) {
return Ok(());
}
for op in 0..=seq.operating_points_cnt_minus_1 {
if seq.decoder_model_present_for_this_op[op] {
let idc = seq.operating_point_idc[op];
let in_t = (idc >> temporal_id) & 1;
let in_s = (idc >> (spatial_id + 8)) & 1;
if idc == 0 || (in_t == 1 && in_s == 1) {
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
}
}
}
Ok(())
}
impl FrameHeader {
pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 {
relative_dist(seq, a, b)
@@ -487,35 +526,10 @@ impl FrameHeader {
} else {
h.show_existing_frame = r.flag()?;
if h.show_existing_frame {
h.frame_to_show_map_idx = r.f(3)? as usize;
if seq.decoder_model_info_present && !seq.equal_picture_interval {
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
}
if seq.frame_id_numbers_present {
r.f(id_len)?;
}
let rs = &st.refs[h.frame_to_show_map_idx];
if !rs.valid {
return Err(Error::Invalid("show_existing_frame of an empty slot"));
}
h.frame_type = rs.frame_type;
if h.frame_type == KEY_FRAME as u8 {
h.refresh_frame_flags = all_frames;
}
if seq.film_grain_params_present {
h.film_grain = rs.grain.clone();
}
h.parse_show_existing_frame(r, st, id_len)?;
return Ok(h);
}
h.frame_type = r.f(2)? as u8;
h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8;
h.show_frame = r.flag()?;
if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
}
h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? };
h.error_resilient_mode =
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? };
h.parse_frame_type_and_flags(r, seq)?;
}
if h.frame_type == KEY_FRAME as u8 && h.show_frame {
for rs in st.refs.iter_mut() {
@@ -526,33 +540,11 @@ impl FrameHeader {
h.order_hints[LAST_FRAME + i] = 0;
}
}
h.disable_cdf_update = r.flag()?;
h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
r.flag()?
} else {
seq.seq_force_screen_content_tools != 0
};
if h.allow_screen_content_tools {
h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
}
if h.frame_is_intra {
h.force_integer_mv = true;
}
h.parse_screen_content_flags(r, seq)?;
if seq.frame_id_numbers_present {
h.current_frame_id = r.f(id_len)?;
// mark_ref_frames( idLen )
let diff_len = seq.delta_frame_id_length_minus_2 + 2;
let cur = h.current_frame_id;
for rs in st.refs.iter_mut() {
if cur > (1 << diff_len) {
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
rs.valid = false;
}
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
rs.valid = false;
}
}
st.current_frame_id = cur;
mark_ref_frames(st, h.current_frame_id, id_len);
st.current_frame_id = h.current_frame_id;
}
h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 {
true
@@ -563,18 +555,7 @@ impl FrameHeader {
};
h.order_hint = r.f(seq.order_hint_bits)?;
h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize };
if seq.decoder_model_info_present && r.flag()? {
for op in 0..=seq.operating_points_cnt_minus_1 {
if seq.decoder_model_present_for_this_op[op] {
let idc = seq.operating_point_idc[op];
let in_t = (idc >> temporal_id) & 1;
let in_s = (idc >> (spatial_id + 8)) & 1;
if idc == 0 || (in_t == 1 && in_s == 1) {
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
}
}
}
}
skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?;
h.refresh_frame_flags =
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? };
if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint {
@@ -592,126 +573,16 @@ impl FrameHeader {
h.allow_intrabc = r.flag()?;
}
} else {
let mut short = false;
if seq.enable_order_hint {
short = r.flag()?;
if short {
let last = r.f(3)? as usize;
let gold = r.f(3)? as usize;
h.set_frame_refs(seq, st.refs, last, gold);
}
}
for i in 0..REFS_PER_FRAME {
if !short {
h.ref_frame_idx[i] = r.f(3)? as usize;
}
if seq.frame_id_numbers_present {
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
}
}
for i in 0..REFS_PER_FRAME {
if !st.refs[h.ref_frame_idx[i]].valid {
return Err(Error::Invalid("reference to an empty slot"));
}
}
if h.frame_size_override_flag && !h.error_resilient_mode {
// frame_size_with_refs( )
let mut found = false;
for i in 0..REFS_PER_FRAME {
if r.flag()? {
let rs = &st.refs[h.ref_frame_idx[i]];
h.upscaled_width = rs.upscaled_width;
h.frame_width = h.upscaled_width;
h.frame_height = rs.frame_height;
h.render_width = rs.render_width;
h.render_height = rs.render_height;
found = true;
break;
}
}
if !found {
h.frame_size(r, seq)?;
h.render_size(r)?;
} else {
h.superres_params(r, seq)?;
h.compute_image_size();
}
} else {
h.frame_size(r, seq)?;
h.render_size(r)?;
}
h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? };
h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
h.is_motion_mode_switchable = r.flag()?;
h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
for i in 0..REFS_PER_FRAME {
let rf = LAST_FRAME + i;
let hint = st.refs[h.ref_frame_idx[i]].order_hint;
h.order_hints[rf] = hint;
h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0;
}
h.parse_inter_frame_refs(r, st)?;
}
h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? };
if h.primary_ref_frame == PRIMARY_REF_NONE {
// setup_past_independence( )
st.seg_features = SegmentationFeatures::default();
st.prev_gm_params = default_gm_params();
st.lf_deltas = LoopFilterDeltas::defaults();
h.lf.delta_enabled = true;
} else {
// load_previous( )
let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]];
st.prev_gm_params = prev.gm_params;
st.lf_deltas = prev.lf_deltas;
st.seg_features = prev.seg_features;
}
h.setup_or_load_previous(st);
h.prev_gm_params = st.prev_gm_params;
h.tile_info(r, seq)?;
h.quantization_params(r, seq)?;
h.segmentation_params(r, st)?;
// delta_q_params( )
if h.quant.base_q_idx > 0 {
h.delta_q_present = r.flag()?;
}
if h.delta_q_present {
h.delta_q_res = r.f(2)?;
}
// delta_lf_params( )
if h.delta_q_present {
if !h.allow_intrabc {
h.delta_lf_present = r.flag()?;
}
if h.delta_lf_present {
h.delta_lf_res = r.f(2)?;
h.delta_lf_multi = r.flag()?;
}
}
h.coded_lossless = true;
for seg_id in 0..MAX_SEGMENTS {
let q = h.get_qindex(true, seg_id, 0);
let ll = q == 0
&& h.quant.delta_q_y_dc == 0
&& h.quant.delta_q_u_ac == 0
&& h.quant.delta_q_u_dc == 0
&& h.quant.delta_q_v_ac == 0
&& h.quant.delta_q_v_dc == 0;
h.lossless_array[seg_id] = ll;
if !ll {
h.coded_lossless = false;
}
if h.quant.using_qmatrix {
if ll {
h.seg_qm_level[0][seg_id] = 15;
h.seg_qm_level[1][seg_id] = 15;
h.seg_qm_level[2][seg_id] = 15;
} else {
h.seg_qm_level[0][seg_id] = h.quant.qm_y;
h.seg_qm_level[1][seg_id] = h.quant.qm_u;
h.seg_qm_level[2][seg_id] = h.quant.qm_v;
}
}
}
h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width;
h.delta_q_lf_params(r)?;
h.compute_lossless();
h.loop_filter_params(r, seq, st)?;
h.cdef_params(r, seq)?;
h.lr_params(r, seq)?;
@@ -732,6 +603,182 @@ impl FrameHeader {
Ok(h)
}
/// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain.
fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> {
let seq = st.seq;
self.frame_to_show_map_idx = r.f(3)? as usize;
if seq.decoder_model_info_present && !seq.equal_picture_interval {
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
}
if seq.frame_id_numbers_present {
r.f(id_len)?;
}
let rs = &st.refs[self.frame_to_show_map_idx];
if !rs.valid {
return Err(Error::Invalid("show_existing_frame of an empty slot"));
}
self.frame_type = rs.frame_type;
if self.frame_type == KEY_FRAME as u8 {
self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1;
}
if seq.film_grain_params_present {
self.film_grain = rs.grain.clone();
}
Ok(())
}
/// frame_type, show_frame, showable_frame and error_resilient_mode.
fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
self.frame_type = r.f(2)? as u8;
self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8;
self.show_frame = r.flag()?;
if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
}
self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? };
self.error_resilient_mode =
if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? };
Ok(())
}
/// disable_cdf_update, allow_screen_content_tools and force_integer_mv.
fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
self.disable_cdf_update = r.flag()?;
self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
r.flag()?
} else {
seq.seq_force_screen_content_tools != 0
};
if self.allow_screen_content_tools {
self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
}
if self.frame_is_intra {
self.force_integer_mv = true;
}
Ok(())
}
/// Reference selection, frame size, motion vector precision and filter flags of an inter frame.
fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> {
let seq = st.seq;
let mut short = false;
if seq.enable_order_hint {
short = r.flag()?;
if short {
let last = r.f(3)? as usize;
let gold = r.f(3)? as usize;
self.set_frame_refs(seq, st.refs, last, gold);
}
}
for i in 0..REFS_PER_FRAME {
if !short {
self.ref_frame_idx[i] = r.f(3)? as usize;
}
if seq.frame_id_numbers_present {
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
}
}
for i in 0..REFS_PER_FRAME {
if !st.refs[self.ref_frame_idx[i]].valid {
return Err(Error::Invalid("reference to an empty slot"));
}
}
if self.frame_size_override_flag && !self.error_resilient_mode {
self.frame_size_with_refs(r, seq, st.refs)?;
} else {
self.frame_size(r, seq)?;
self.render_size(r)?;
}
self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? };
self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
self.is_motion_mode_switchable = r.flag()?;
self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
for i in 0..REFS_PER_FRAME {
let rf = LAST_FRAME + i;
let hint = st.refs[self.ref_frame_idx[i]].order_hint;
self.order_hints[rf] = hint;
self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0;
}
Ok(())
}
/// frame_size_with_refs( ).
fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> {
for i in 0..REFS_PER_FRAME {
if r.flag()? {
let rs = &refs[self.ref_frame_idx[i]];
self.upscaled_width = rs.upscaled_width;
self.frame_width = self.upscaled_width;
self.frame_height = rs.frame_height;
self.render_width = rs.render_width;
self.render_height = rs.render_height;
self.superres_params(r, seq)?;
self.compute_image_size();
return Ok(());
}
}
self.frame_size(r, seq)?;
self.render_size(r)
}
/// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame.
fn setup_or_load_previous(&mut self, st: &mut HeaderState) {
if self.primary_ref_frame == PRIMARY_REF_NONE {
st.seg_features = SegmentationFeatures::default();
st.prev_gm_params = default_gm_params();
st.lf_deltas = LoopFilterDeltas::defaults();
self.lf.delta_enabled = true;
} else {
let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]];
st.prev_gm_params = prev.gm_params;
st.lf_deltas = prev.lf_deltas;
st.seg_features = prev.seg_features;
}
}
/// delta_q_params( ) and delta_lf_params( ).
fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> {
if self.quant.base_q_idx > 0 {
self.delta_q_present = r.flag()?;
}
if self.delta_q_present {
self.delta_q_res = r.f(2)?;
if !self.allow_intrabc {
self.delta_lf_present = r.flag()?;
}
if self.delta_lf_present {
self.delta_lf_res = r.f(2)?;
self.delta_lf_multi = r.flag()?;
}
}
Ok(())
}
/// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags.
fn compute_lossless(&mut self) {
self.coded_lossless = true;
for seg_id in 0..MAX_SEGMENTS {
let q = self.get_qindex(true, seg_id, 0);
let ll = q == 0
&& self.quant.delta_q_y_dc == 0
&& self.quant.delta_q_u_ac == 0
&& self.quant.delta_q_u_dc == 0
&& self.quant.delta_q_v_ac == 0
&& self.quant.delta_q_v_dc == 0;
self.lossless_array[seg_id] = ll;
if !ll {
self.coded_lossless = false;
}
if self.quant.using_qmatrix {
let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] };
for (plane, level) in levels.into_iter().enumerate() {
self.seg_qm_level[plane][seg_id] = level;
}
}
}
self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width;
}
fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
self.use_superres = if seq.enable_superres { r.flag()? } else { false };
self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 };
@@ -899,6 +946,9 @@ impl FrameHeader {
let mut widest = 0;
let mut start = 0;
while start < sb_cols {
if t.mi_col_starts.len() >= MAX_TILE_COLS {
return Err(Error::Invalid("tile_cols exceeds MAX_TILE_COLS"));
}
t.mi_col_starts.push(start << sb_shift);
let max_width = (sb_cols - start).min(max_tile_width_sb);
let size = r.ns(max_width)? + 1;
@@ -916,6 +966,9 @@ impl FrameHeader {
let max_tile_height_sb = (max_tile_area_sb / widest.max(1)).max(1);
let mut start = 0;
while start < sb_rows {
if t.mi_row_starts.len() >= MAX_TILE_ROWS {
return Err(Error::Invalid("tile_rows exceeds MAX_TILE_ROWS"));
}
t.mi_row_starts.push(start << sb_shift);
let max_height = (sb_rows - start).min(max_tile_height_sb);
let size = r.ns(max_height)? + 1;
+72 -50
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;
@@ -411,20 +420,8 @@ impl TileDecoder<'_, '_> {
let fh = &self.fs.fh;
for ref_list in 0..1 + is_compound as usize {
let ref_frame = cand_refs[ref_list];
let ym = self.b.y_mode;
let global =
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
let use_warp = if w < 8 || h < 8 || fh.force_integer_mv {
0
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
1
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
2
} else {
0
};
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
if use_warp != 0 {
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
@@ -439,40 +436,7 @@ impl TileDecoder<'_, '_> {
}
}
let ct = self.b.compound_type;
if ct == COMPOUND_WEDGE && plane == 0 {
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
}
}
} else if ct == COMPOUND_INTRA {
let size_scale = MAX_SB_SIZE / h.max(w);
let im = self.b.interintra_mode;
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
II_WEIGHTS_1D[i * size_scale] as i32
} else if im == II_H_PRED {
II_WEIGHTS_1D[j * size_scale] as i32
} else if im == II_SMOOTH_PRED {
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
} else {
32
};
}
}
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
for i in 0..h {
for j in 0..w {
let k = i * PRED_STRIDE + j;
let diff = (preds[0][k] - preds[1][k]).abs();
let diff = round2(diff, (bd - 8) + rnd.2);
let m = (38 + diff / 16).clamp(0, 64);
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
}
}
}
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
let max = (1i32 << bd) - 1;
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
@@ -512,6 +476,64 @@ impl TileDecoder<'_, '_> {
}
}
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
let fh = &self.fs.fh;
let ym = self.b.y_mode;
let global =
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
if w < 8 || h < 8 || fh.force_integer_mv {
0
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
1
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
2
} else {
0
}
}
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
let bd = self.fs.bit_depth;
let ct = self.b.compound_type;
if ct == COMPOUND_WEDGE && plane == 0 {
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
}
}
} else if ct == COMPOUND_INTRA {
let size_scale = MAX_SB_SIZE / h.max(w);
let im = self.b.interintra_mode;
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
II_WEIGHTS_1D[i * size_scale] as i32
} else if im == II_H_PRED {
II_WEIGHTS_1D[j * size_scale] as i32
} else if im == II_SMOOTH_PRED {
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
} else {
32
};
}
}
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
for i in 0..h {
for j in 0..w {
let k = i * PRED_STRIDE + j;
let diff = (preds[0][k] - preds[1][k]).abs();
let diff = round2(diff, (bd - 8) + round_post);
let m = (38 + diff / 16).clamp(0, 64);
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
}
}
}
}
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
if ref_idx < 0 {
return false;
@@ -678,7 +700,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 +721,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};
+118 -79
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).
@@ -69,57 +73,70 @@ impl LrCtx<'_> {
}
}
/// Position and size of a (at most 4x4) restoration block within its plane.
#[derive(Clone, Copy)]
struct LrBlock {
x: i32,
y: i32,
w: i32,
h: i32,
}
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
let fh = &fs.fh;
let mut lr = cdef.clone();
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
for plane in 0..fs.num_planes {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
}
lr
}
#[allow(clippy::too_many_arguments)]
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn restore_plane(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, out: &mut Plane, plane: usize) {
let fh = &fs.fh;
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
let blk = LrBlock { x, y, w, h };
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, (plane, ui), blk, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, (plane, ui), blk, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
}
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
let LrBlock { x, y, w, h } = blk;
let round0: u32 = if bd == 12 { 5 } else { 3 };
let round1: u32 = if bd == 12 { 9 } else { 11 };
let coef = &fs.lr.lr_wiener[plane][ui];
@@ -162,13 +179,13 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
}
}
#[allow(clippy::too_many_arguments)]
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
let LrBlock { x, y, w, h } = blk;
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
let mut flt0 = [[0i32; 4]; 4];
let mut flt1 = [[0i32; 4]; 4];
box_filter(ctx, x, y, w, h, set, 0, bd, &mut flt0);
box_filter(ctx, x, y, w, h, set, 1, bd, &mut flt1);
box_filter(ctx, blk, (set, 0), bd, &mut flt0);
box_filter(ctx, blk, (set, 1), bd, &mut flt1);
let xqd = fs.lr.lr_sgr_xqd[plane][ui];
let w0 = xqd[0] as i32;
let w1 = xqd[1] as i32;
@@ -194,8 +211,56 @@ fn round2_64(x: i64, n: u32) -> i64 {
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
}
#[allow(clippy::too_many_arguments)]
fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usize, bd: u32, f: &mut [[i32; 4]; 4]) {
/// Box sums of the samples within `r` of (x, y): (sum of squares, sum).
fn box_sums(ctx: &LrCtx, x: i32, y: i32, r: i32) -> (i64, i64) {
let mut a = 0i64;
let mut b = 0i64;
for dy in -r..=r {
for dx in -r..=r {
let c = ctx.sample(x + dx, y + dy) as i64;
a += c * c;
b += c;
}
}
(a, b)
}
/// Self-guided filter constants of one pass.
struct SgrConsts {
n: i64,
s: i64,
one_over_n: i64,
}
/// (A, B) coefficients of one sample from its box sums.
fn sgr_coefficients(a: i64, b: i64, k: &SgrConsts, bd: u32) -> (i64, i64) {
let a = round2_64(a, 2 * (bd - 8));
let d = round2_64(b, bd - 8);
let p = (a * k.n - d * d).max(0);
let z = round2_64(p * k.s, SGRPROJ_MTABLE_BITS as u32);
let a2 = if z >= 255 {
256
} else if z == 0 {
1
} else {
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
};
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * k.one_over_n;
(a2, round2_64(b2, SGRPROJ_RECIP_BITS as u32))
}
fn box_weight(pass: usize, i: i32, dx: i32, dy: i32) -> i64 {
if pass == 0 {
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
} else if dx == 0 || dy == 0 {
4
} else {
3
}
}
fn box_filter(ctx: &LrCtx, blk: LrBlock, (set, pass): (usize, usize), bd: u32, f: &mut [[i32; 4]; 4]) {
let LrBlock { x, y, w, h } = blk;
let r = SGR_PARAMS[set][pass * 2] as i32;
if r == 0 {
return;
@@ -203,35 +268,15 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
let eps = SGR_PARAMS[set][pass * 2 + 1] as i64;
let n = ((2 * r + 1) * (2 * r + 1)) as i64;
let n2e = n * n * eps;
let s = ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e;
let one_over_n = ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n;
let k = SgrConsts { n, s: ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e, one_over_n: ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n };
let mut a_arr = [[0i64; 6]; 6];
let mut b_arr = [[0i64; 6]; 6];
for i in -1..h + 1 {
for j in -1..w + 1 {
let mut a = 0i64;
let mut b = 0i64;
for dy in -r..=r {
for dx in -r..=r {
let c = ctx.sample(x + j + dx, y + i + dy) as i64;
a += c * c;
b += c;
}
}
let a = round2_64(a, 2 * (bd - 8));
let d = round2_64(b, bd - 8);
let p = (a * n - d * d).max(0);
let z = round2_64(p * s, SGRPROJ_MTABLE_BITS as u32);
let a2 = if z >= 255 {
256
} else if z == 0 {
1
} else {
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
};
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * one_over_n;
let (a, b) = box_sums(ctx, x + j, y + i, r);
let (a2, b2) = sgr_coefficients(a, b, &k, bd);
a_arr[(i + 1) as usize][(j + 1) as usize] = a2;
b_arr[(i + 1) as usize][(j + 1) as usize] = round2_64(b2, SGRPROJ_RECIP_BITS as u32);
b_arr[(i + 1) as usize][(j + 1) as usize] = b2;
}
}
for i in 0..h {
@@ -241,13 +286,7 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
let mut b = 0i64;
for dy in -1..=1i32 {
for dx in -1..=1i32 {
let weight = if pass == 0 {
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
} else if dx == 0 || dy == 0 {
4
} else {
3
};
let weight = box_weight(pass, i, dx, dy);
a += weight * a_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
b += weight * b_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
}
+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;
+106 -69
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;
@@ -1662,6 +1682,87 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
}
}
/// eob_pt, eob_extra and the remaining eob bits.
fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize {
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
let ectx = (tx_class != TX_CLASS_2D) as usize;
let eob_pt = 1 + match eob_multisize {
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
};
let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
let eob_shift = eob_pt as i32 - 3;
if eob_shift >= 0 {
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
if extra == 1 {
eob += 1 << eob_shift;
}
for i in 1..(eob_pt as i32 - 2).max(0) {
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
if self.sd.read_literal(1) == 1 {
eob += 1 << sh;
}
}
}
eob
}
/// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb).
fn read_golomb(&mut self) -> u32 {
let mut length = 0;
loop {
length += 1;
if self.sd.read_literal(1) == 1 || length > 32 {
break;
}
}
let mut x: u32 = 1;
for _ in (0..length - 1).rev() {
x = (x << 1) | self.sd.read_literal(1);
}
x
}
/// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies
/// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4).
fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) {
let (x4, y4, w4, h4) = blk4;
let ptype = (plane > 0) as usize;
let mut cul_level = 0u32;
let mut dc_category = 0u8;
for (c, &p) in scan.iter().enumerate() {
let pos = p as usize;
let sign = if self.quant[pos] != 0 {
if c == 0 {
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
} else {
self.sd.read_literal(1) == 1
}
} else {
false
};
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
let x = self.read_golomb();
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
}
if pos == 0 && self.quant[pos] > 0 {
dc_category = if sign { 1 } else { 2 };
}
self.quant[pos] &= 0xFFFFF;
cul_level += self.quant[pos] as u32;
if sign {
self.quant[pos] = -self.quant[pos];
}
}
(cul_level.min(63), dc_category)
}
/// coeffs( ): returns eob.
fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize {
let x4 = start_x >> 2;
@@ -1687,32 +1788,8 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
}
self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4);
let scan = self.get_scan(tx_sz);
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
let tx_class = get_tx_class(self.plane_tx_type);
let ectx = (tx_class != TX_CLASS_2D) as usize;
let eob_pt = 1 + match eob_multisize {
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
};
eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
let eob_shift = eob_pt as i32 - 3;
if eob_shift >= 0 {
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
if extra == 1 {
eob += 1 << eob_shift;
}
for i in 1..(eob_pt as i32 - 2).max(0) {
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
if self.sd.read_literal(1) == 1 {
eob += 1 << sh;
}
}
}
eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class);
let adj = ADJUSTED_TX_SIZE[tx_sz] as usize;
let bwl = TX_WIDTH_LOG2[adj] as usize;
let height = TX_HEIGHT[adj] as usize;
@@ -1760,45 +1837,7 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
lev[li] = level as u8;
}
self.levels = lev;
for c in 0..eob {
let pos = scan[c] as usize;
let sign = if self.quant[pos] != 0 {
if c == 0 {
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
} else {
self.sd.read_literal(1) == 1
}
} else {
false
};
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
let mut length = 0;
loop {
length += 1;
if self.sd.read_literal(1) == 1 {
break;
}
if length > 32 {
break;
}
}
let mut x: u32 = 1;
for _ in (0..length - 1).rev() {
x = (x << 1) | self.sd.read_literal(1);
}
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
}
if pos == 0 && self.quant[pos] > 0 {
dc_category = if sign { 1 } else { 2 };
}
self.quant[pos] &= 0xFFFFF;
cul_level += self.quant[pos] as u32;
if sign {
self.quant[pos] = -self.quant[pos];
}
}
cul_level = cul_level.min(63);
(cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]);
}
for i in 0..w4 {
if x4 + i < self.t.above_level[plane].len() {
@@ -2222,18 +2261,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)
}
+52 -5
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 {
@@ -101,7 +104,7 @@ impl<const L: usize> Lane for [i32; L] {
}
#[inline(always)]
fn neg(self) -> Self {
self.map(|v| v.wrapping_neg())
self.map(i32::wrapping_neg)
}
#[inline(always)]
fn mul_round12(self, m: i32) -> Self {
@@ -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"] }
+4 -485
View File
@@ -1,495 +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.
//! - [`budget`]: the memory budget video decoders check their picture store against.
//! - [`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::*;
pub mod budget;
#[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,
/// A NAL length prefix width outside 1..=4 bytes.
InvalidLengthSize,
}
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"),
BitError::InvalidLengthSize => f.write_str("unsupported NAL length prefix size"),
}
}
}
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)`. Codes whose value does not fit an `i32` (the code for `2^31`) are rejected.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
}
/// 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) {
self.write_ue_u64(v as u64);
}
fn write_ue_u64(&mut self, v: u64) {
let x = v + 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);
}
}
/// Signed Exp-Golomb `se(v)`. `i32::MIN` needs a code above `u32::MAX`, which `read_se` rejects.
pub fn write_se(&mut self, v: i32) {
let v = v as i64;
let k = if v > 0 { (v as u64) * 2 - 1 } else { ((-v) as u64) * 2 };
self.write_ue_u64(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..=4 bytes, as the 2-bit `lengthSizeMinusOne` allows) into NAL units.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
if !(1..=4).contains(&length_size) {
return Err(BitError::InvalidLengthSize);
}
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
if length_size > data.len() - i {
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 len > data.len() - i {
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 signed_exp_golomb_extremes() {
let mut w = BitWriter::new();
for v in [i32::MAX, i32::MIN + 1, i32::MIN] {
w.write_se(v);
}
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_se().unwrap(), i32::MAX);
assert_eq!(r.read_se().unwrap(), i32::MIN + 1);
assert_eq!(r.read_se(), Err(BitError::InvalidExpGolomb));
// ue code 0xFFFF_FFFF maps to +2^31, which must not wrap to a negative i32.
let mut w = BitWriter::new();
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
assert_eq!(BitReader::new(&b).read_se(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn length_prefixed_rejects_bad_width_and_oversized_length() {
assert_eq!(length_prefixed_nals(&[1, 2, 3, 4, 5, 6], 5), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
}
#[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)); }
}
}
}
+258 -2
View File
@@ -34,8 +34,9 @@ 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() {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
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());
@@ -515,10 +516,265 @@ pub fn preset(name: &str) -> Option<Rgba> {
Some(Rgba::rgb(c.0, c.1, c.2))
}
/// Every preset colour name a file may write (`ST_PresetColorVal`, with the later editions' aliases).
pub const PRESET_NAMES: [&str; 190] = [
"aliceBlue",
"antiqueWhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedAlmond",
"blue",
"blueViolet",
"brown",
"burlyWood",
"cadetBlue",
"chartreuse",
"chocolate",
"coral",
"cornflowerBlue",
"cornsilk",
"crimson",
"cyan",
"darkBlue",
"darkCyan",
"darkGoldenrod",
"darkGray",
"darkGrey",
"darkGreen",
"darkKhaki",
"darkMagenta",
"darkOliveGreen",
"darkOrange",
"darkOrchid",
"darkRed",
"darkSalmon",
"darkSeaGreen",
"darkSlateBlue",
"darkSlateGray",
"darkSlateGrey",
"darkTurquoise",
"darkViolet",
"dkBlue",
"dkCyan",
"dkGoldenrod",
"dkGray",
"dkGrey",
"dkGreen",
"dkKhaki",
"dkMagenta",
"dkOliveGreen",
"dkOrange",
"dkOrchid",
"dkRed",
"dkSalmon",
"dkSeaGreen",
"dkSlateBlue",
"dkSlateGray",
"dkSlateGrey",
"dkTurquoise",
"dkViolet",
"deepPink",
"deepSkyBlue",
"dimGray",
"dimGrey",
"dodgerBlue",
"firebrick",
"floralWhite",
"forestGreen",
"fuchsia",
"gainsboro",
"ghostWhite",
"gold",
"goldenrod",
"gray",
"grey",
"green",
"greenYellow",
"honeydew",
"hotPink",
"indianRed",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderBlush",
"lawnGreen",
"lemonChiffon",
"lightBlue",
"lightCoral",
"lightCyan",
"lightGoldenrodYellow",
"lightGray",
"lightGrey",
"lightGreen",
"lightPink",
"lightSalmon",
"lightSeaGreen",
"lightSkyBlue",
"lightSlateGray",
"lightSlateGrey",
"lightSteelBlue",
"lightYellow",
"ltBlue",
"ltCoral",
"ltCyan",
"ltGoldenrodYellow",
"ltGray",
"ltGrey",
"ltGreen",
"ltPink",
"ltSalmon",
"ltSeaGreen",
"ltSkyBlue",
"ltSlateGray",
"ltSlateGrey",
"ltSteelBlue",
"ltYellow",
"lime",
"limeGreen",
"linen",
"magenta",
"maroon",
"medAquamarine",
"medBlue",
"medOrchid",
"medPurple",
"medSeaGreen",
"medSlateBlue",
"medSpringGreen",
"medTurquoise",
"medVioletRed",
"mediumAquamarine",
"mediumBlue",
"mediumOrchid",
"mediumPurple",
"mediumSeaGreen",
"mediumSlateBlue",
"mediumSpringGreen",
"mediumTurquoise",
"mediumVioletRed",
"midnightBlue",
"mintCream",
"mistyRose",
"moccasin",
"navajoWhite",
"navy",
"oldLace",
"olive",
"oliveDrab",
"orange",
"orangeRed",
"orchid",
"paleGoldenrod",
"paleGreen",
"paleTurquoise",
"paleVioletRed",
"papayaWhip",
"peachPuff",
"peru",
"pink",
"plum",
"powderBlue",
"purple",
"red",
"rosyBrown",
"royalBlue",
"saddleBrown",
"salmon",
"sandyBrown",
"seaGreen",
"seaShell",
"sienna",
"silver",
"skyBlue",
"slateBlue",
"slateGray",
"slateGrey",
"snow",
"springGreen",
"steelBlue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whiteSmoke",
"yellow",
"yellowGreen",
];
/// Every system colour name a file may write (`ST_SystemColorVal`).
pub const SYSTEM_NAMES: [&str; 30] = [
"scrollBar",
"background",
"activeCaption",
"inactiveCaption",
"menu",
"window",
"windowFrame",
"menuText",
"windowText",
"captionText",
"activeBorder",
"inactiveBorder",
"appWorkspace",
"highlight",
"highlightText",
"btnFace",
"btnShadow",
"grayText",
"btnText",
"inactiveCaptionText",
"btnHighlight",
"3dDkShadow",
"3dLight",
"infoText",
"infoBk",
"hotLight",
"gradientActiveCaption",
"gradientInactiveCaption",
"menuHighlight",
"menuBar",
];
/// Whether `name` is a preset colour name a file may write.
pub fn is_preset_name(name: &str) -> bool {
PRESET_NAMES.contains(&name)
}
/// Whether `name` is a system colour name a file may write.
pub fn is_system_name(name: &str) -> bool {
SYSTEM_NAMES.contains(&name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_hex_rejects_multiple_hashes() {
assert_eq!(Rgba::from_hex("##FF0000"), None);
assert_eq!(Rgba::from_hex("####FF0000"), None);
assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128)));
}
#[test]
fn from_hex_rejects_signs() {
assert_eq!(Rgba::from_hex("+12345"), None);
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)));
+1
View File
@@ -25,6 +25,7 @@ i_overlay = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
deckcraft-anim = { workspace = true }
@@ -318,6 +318,14 @@ 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())
}
/// 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>> {
@@ -347,6 +355,11 @@ pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
/// Targets: `ids` / `id` params, else the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
if let Some(a) = p.get("ids").and_then(Value::as_array)
&& let Some(v) = a.iter().find(|v| v.as_u64().and_then(|v| u32::try_from(v).ok()).is_none())
{
return Err(bad("shape", format!("`ids` has an invalid shape id: {v}")));
}
if let Some(ids) = ids_param(p, "ids")
&& !ids.is_empty()
{
@@ -363,6 +376,8 @@ pub(crate) fn ok() -> Result<Value> {
}
/// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`.
/// A preset or system colour keeps its base: `{"preset":"red"}`,
/// `{"system":"windowText","last":"#000000"}`.
pub(crate) fn color_param(p: &Value, key: &str) -> Option<deckcraft_model::ColorRef> {
let v = p.get(key)?;
color_value(v)
@@ -383,6 +398,17 @@ pub(crate) fn color_value(v: &Value) -> Option<deckcraft_model::ColorRef> {
Value::Object(o) => {
let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) {
ColorRef::scheme(deckcraft_color::SchemeSlot::from_xml(s)?)
} else if let Some(name) = o.get("preset").and_then(Value::as_str) {
if !deckcraft_color::is_preset_name(name) {
return None;
}
ColorRef { base: deckcraft_model::ColorBase::Preset { name: name.to_string() }, mods: vec![] }
} else if let Some(name) = o.get("system").and_then(Value::as_str) {
if !deckcraft_color::is_system_name(name) {
return None;
}
let last = deckcraft_color::Rgba::from_hex(o.get("last").and_then(Value::as_str)?)?;
ColorRef { base: deckcraft_model::ColorBase::System { name: name.to_string(), last }, mods: vec![] }
} else {
ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?)
};
@@ -489,6 +515,10 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
}
}
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
}
/// The command palette runs a command with `{}`. True when the params doc names a value that
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
@@ -631,4 +661,22 @@ mod tests {
assert!(!needs_arguments(save.params));
assert!(needs_arguments(audio.params));
}
#[test]
fn preset_and_system_colors_keep_their_base() {
use deckcraft_color::{ColorTransform, Rgba};
use deckcraft_model::{ColorBase, ColorRef};
use serde_json::json;
let preset = color_value(&json!({"preset": "red", "lumMod": 50_000}));
assert_eq!(preset, Some(ColorRef { base: ColorBase::Preset { name: "red".into() }, mods: vec![ColorTransform::LumMod(50_000)] }));
let system = color_value(&json!({"system": "windowText", "last": "#123456"}));
assert_eq!(system, Some(ColorRef { base: ColorBase::System { name: "windowText".into(), last: Rgba::rgb(0x12, 0x34, 0x56) }, mods: vec![] }));
assert_eq!(color_value(&json!({"system": "windowText"})), None, "a system color needs its last value");
assert_eq!(color_value(&json!({"preset": "notAColor"})), None);
assert_eq!(color_value(&json!({"system": "notAColor", "last": "#000000"})), None);
for name in ["aliceBlue", "darkBlue", "ltSlateGrey"] {
assert!(color_value(&json!({"preset": name})).is_some(), "{name}");
}
assert!(color_value(&json!({"system": "3dDkShadow", "last": "#000000"})).is_some());
}
}
+69 -4
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -230,7 +232,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation.remove")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?);
@@ -247,7 +249,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?;
let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?;
let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?;
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?);
@@ -263,7 +265,7 @@ fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?);
@@ -525,4 +527,67 @@ 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);
}
#[test]
fn invalid_index_is_an_error_not_the_selection() {
let mut s = Session::with_new();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
let id = r.get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
s.select(|_, sel| {
sel.shapes = vec![deckcraft_model::ShapeId(id as u32)];
})
.unwrap();
for bad_index in [json!(-1), json!(1.5), json!("x")] {
for cmd in ["animation.remove", "animation.timing", "animation.options"] {
let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100}));
assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}");
}
let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0}));
assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}");
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
}
}
}
+27 -5
View File
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -171,6 +174,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
Some(it.fold(first, |a, b| a.union(b)))
}
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
/// the shape tree stop at 64.
pub const MAX_GROUP_DEPTH: usize = 32;
/// How many groups deep `shapes` nest (0 without groups).
fn group_depth(shapes: &[Shape]) -> usize {
let mut d = 0;
deckcraft_model::walk(shapes, &mut |sh, depth| {
if sh.is_group() {
d = d.max(depth + 1);
}
});
d
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|doc, sel| {
@@ -179,6 +198,9 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
if members.len() < 2 {
return Err(bad("arrange.group", "select two or more objects on the slide"));
}
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
}
for m in &mut members {
if m.xfrm.is_none() {
m.xfrm = Some(xfrm_of(doc, sel, m));
@@ -208,7 +230,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));
@@ -322,9 +344,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let (start, end) = if to_slide {
if horiz { (0.0, size.width) } else { (0.0, size.height) }
} else {
let f = boxes.first().map(|b| b.1).unwrap_or_default();
let l = boxes.last().map(|b| b.1).unwrap_or_default();
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
};
let n = boxes.len();
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
+1 -1
View File
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
use image::{GrayImage, Rgba, RgbaImage};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
use crate::{Result, Session};
/// The effects offered: `a14` element name and label.
+6 -1
View File
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
use crate::{Result, Session};
/// Larger pictures are refused rather than matted slowly (16 megapixels).
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::*;
use crate::Session;
use crate::cmd::base64_encode;
/// A red disc on a white-to-grey gradient.
fn disc() -> image::RgbaImage {
+1 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use crate::*;
use crate::Session;
fn deck() -> Session {
let mut s = Session::with_new();
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
use std::sync::Arc;
use deckcraft_model::text::RunKind;
use deckcraft_model::{Background, Fill, PhType};
use serde_json::json;
use crate::{Session, Target};
#[test]
fn applies_the_theme_of_another_deck_and_undoes() {
let mut other = Session::with_new();
other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap();
other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let t = &s.doc().unwrap().doc.masters[0].theme;
assert_eq!(t.fonts.major.latin, "Lato");
assert_eq!(t.fonts.minor.latin, "Source Sans 3");
assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456");
assert!(s.doc().unwrap().doc.validate().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].theme, before);
assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err());
assert!(s.execute("design.browseTheme", &json!({})).is_err());
}
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
/// can only find them by kind) and its content layout's title moved.
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
let mut logo = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
let mut t = Session::with_new();
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
let title = t
.edit(|doc, _| {
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.push(deckcraft_model::Shape {
id,
name: "Logo".into(),
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
..Default::default()
});
m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
for l in &mut m.layouts {
l.name = format!("Brand {}", l.name);
}
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
title.xfrm = Some(x);
Ok(x)
})
.unwrap();
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
}
#[test]
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
let (data, logo, title) = brand_template();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
assert_eq!(r["slides"], json!(2), "{r}");
assert_eq!(undo_len(&s), n + 1, "one undo step");
let doc = &s.doc().unwrap().doc;
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
let m = &doc.masters[0];
assert_ne!(m.id, before.masters[0].id);
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
// The 4:3 master stretches to this deck's 16:9.
let k = doc.slide_size.width / 720.0;
let px = pic.xfrm.unwrap();
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
// Each slide moved to the brand layout of its kind, and kept its text.
for (old, new) in before.slides.iter().zip(&doc.slides) {
let (_, was) = before.layout(old.layout).unwrap();
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
assert_eq!(owner.id, m.id);
assert_eq!(now.kind, was.kind);
}
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
let (_, lay) = doc.layout(agenda.layout).unwrap();
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.masters, before.masters);
assert_eq!(doc.slides, before.slides);
}
#[test]
fn a_preserved_master_stays_beside_the_template() {
let (data, _, _) = brand_template();
let mut s = Session::with_new();
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
let old = s.doc().unwrap().doc.masters[0].id;
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.masters.len(), 2);
assert_eq!(st.doc.masters[1].id, old);
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
let new = &st.doc.masters[0];
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
}
fn undo_len(s: &crate::Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() {
let mut s = crate::Session::with_new();
let listed = s.execute("design.themes", &json!({})).unwrap();
let names: Vec<String> = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]);
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap();
assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step");
let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string();
assert!(err.contains("no effect scheme"), "{err}");
assert!(s.execute("design.effects", &json!({})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.theme", &json!({"name": "Harbor"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.effects", &json!({"name": "soft"})).unwrap();
let theme = s.doc().unwrap().doc.masters[0].theme.clone();
assert_eq!(theme.name, "Harbor");
assert_eq!(theme.colors, before.colors);
assert_eq!(theme.fonts, before.fonts);
assert_eq!(theme.format.name, "Soft");
assert!(theme.format.effects[0].outer_shadow.is_some());
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects");
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "Soft"})).unwrap();
assert_eq!(undo_len(&s), n, "the same scheme is not an undo step");
s.execute("design.effects", &json!({"name": "Glow"})).unwrap();
let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone();
assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6));
s.execute("design.effects", &json!({"name": "Plain"})).unwrap();
let plain = s.doc().unwrap().doc.masters[0].theme.format.clone();
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
assert_eq!(plain.lines[2].width, Some(0.75));
}
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
s.doc().unwrap().shapes().to_vec()
}
fn has_title(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
}
fn has_footers(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
}
#[test]
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
for id in ["master.preserve", "master.placeholders"] {
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
}
let mut s = Session::with_new();
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
assert!(!s.doc().unwrap().doc.masters[0].preserve);
let n = s.doc().unwrap().history.undo.len();
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
s.execute("master.preserve", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].preserve);
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(&s));
assert!(!has_footers(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(has_footers(&s));
master_view_closed_rejects(&mut s, blank);
}
fn master_view_closed_rejects(s: &mut Session, blank: usize) {
s.execute("view.closeMaster", &json!({})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let err = s.execute("master.preserve", &json!({})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
assert!(err.to_string().contains("give title or footers"), "{err}");
assert_eq!(s.doc().unwrap().history.undo.len(), n);
layout_placeholders_edit_one_undo_step(s, blank);
}
fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert!(!has_title(s));
assert!(has_footers(s));
let n = s.doc().unwrap().history.undo.len();
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(s));
assert!(!has_footers(s));
let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert_eq!(title.name, "Title 1");
assert_eq!(title.ph.unwrap().idx, 0);
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!has_title(s));
assert!(has_footers(s));
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
let restored = shapes(s);
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
assert_eq!(run.text, "‹#›");
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
missing_title_placeholder_is_recreated(s);
}
fn missing_title_placeholder_is_recreated(s: &mut Session) {
s.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
Ok(())
})
.unwrap();
s.execute("master.placeholders", &json!({"title": true})).unwrap();
let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
assert_eq!(title.name, "Title");
let box_ = title.xfrm.unwrap();
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
}
+257
View File
@@ -0,0 +1,257 @@
use super::{
Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param,
};
pub(super) fn colors(s: &mut Session, p: &Value) -> Result<Value> {
let name_v = p.get("name");
let colors_v = p.get("colors");
if name_v.is_none() && colors_v.is_none() {
let sc = current_colors(s)?;
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) });
return ok();
}
let given = match name_v {
None => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty() {
return Err(bad("design.colors", "name is empty"));
}
Some(n.to_string())
}
Some(_) => return Err(bad("design.colors", "`name` must be a string")),
};
let slots = parse_color_slots(colors_v)?;
let current = current_colors(s)?;
let next = next_scheme(&current, given.as_deref(), slots.as_deref())?;
if next == current {
return ok();
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?);
m.theme.colors = next;
Ok(())
})?;
ok()
}
pub(super) fn variant(s: &mut Session, p: &Value) -> Result<Value> {
let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize;
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?);
let v = deckcraft_model::theme::theme_variants(&m.theme);
m.theme.colors =
v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?;
Ok(())
})?;
Ok(json!({"variant": index}))
}
pub(super) fn variants(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default();
let v = deckcraft_model::theme::theme_variants(&th);
let current = v.iter().position(|c| c.colors == th.colors.colors);
Ok(json!({
"theme": th.name,
"current": current,
"variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::<Vec<_>>()})).collect::<Vec<_>>(),
}))
}
fn current_colors(s: &Session) -> Result<deckcraft_color::ColorScheme> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master"))
}
fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value {
let mut colors = serde_json::Map::new();
for slot in deckcraft_color::SchemeSlot::THEME {
colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex())));
}
json!({"name": sc.name, "colors": colors})
}
/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such
/// as `bg1` are rejected because `ColorScheme::set` ignores them.
fn parse_color_slots(colors_v: Option<&Value>) -> Result<Option<Vec<(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)>>> {
let Some(v) = colors_v else { return Ok(None) };
let Some(map) = v.as_object() else {
return Err(bad("design.colors", "`colors` must be an object"));
};
if map.is_empty() {
return Err(bad("design.colors", "`colors` is empty"));
}
let mut slots = Vec::with_capacity(map.len());
for (k, v) in map {
let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else {
return Err(bad("design.colors", format!("unknown colour `{k}`")));
};
if !deckcraft_color::SchemeSlot::THEME.contains(&slot) {
return Err(bad("design.colors", format!("`{k}` is not a theme colour")));
}
let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else {
return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB")));
};
slots.push((slot, rgb));
}
Ok(Some(slots))
}
/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name
/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error.
fn next_scheme(
current: &deckcraft_color::ColorScheme,
given: Option<&str>,
slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>,
) -> Result<deckcraft_color::ColorScheme> {
let mut next = current.clone();
if let Some(name) = given {
if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) {
next = builtin;
} else if slots.is_none() {
return Err(bad("design.colors", format!("no colour scheme `{name}`")));
}
next.name = name.to_string();
}
if let Some(changes) = slots {
for (slot, rgb) in changes {
next.set(*slot, *rgb);
}
if given.is_none() {
next.name = "Custom".into();
}
}
Ok(next)
}
pub(super) fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let name_v = p.get("name");
let major_v = p.get("major");
let minor_v = p.get("minor");
if name_v.is_none() && major_v.is_none() && minor_v.is_none() {
let scheme = current_fonts(s)?;
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) });
return ok();
}
let given = match name_v {
None => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty() {
return Err(bad("design.fonts", "name is empty"));
}
Some(n.to_string())
}
Some(_) => return Err(bad("design.fonts", "`name` must be a string")),
};
let major = face_param("major", major_v)?;
let minor = face_param("minor", minor_v)?;
let current = current_fonts(s)?;
let next = next_font_scheme(&current, given.as_deref(), major.as_deref(), minor.as_deref())?;
if next == current {
return ok();
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next;
Ok(())
})?;
ok()
}
fn current_fonts(s: &Session) -> Result<deckcraft_model::theme::FontScheme> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master"))
}
fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value {
json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin})
}
/// `None` when the key is absent. A present face must be a non-blank string.
fn face_param(key: &str, v: Option<&Value>) -> Result<Option<String>> {
match v {
None => Ok(None),
Some(Value::String(s)) => {
let s = s.trim();
if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) }
}
Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))),
}
}
/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East
/// Asian, complex-script and per-script slots stay). The stored name is the given string when
/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name.
/// Faces without a name are a custom scheme: an omitted `minor` copies `major`.
fn next_font_scheme(
current: &deckcraft_model::theme::FontScheme,
given: Option<&str>,
major: Option<&str>,
minor: Option<&str>,
) -> Result<deckcraft_model::theme::FontScheme> {
let mut next = current.clone();
if let Some(name) = given {
let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) {
next = builtin;
true
} else {
false
};
if !replaced && major.is_none() && minor.is_none() {
return Err(bad("design.fonts", format!("no font scheme `{name}`")));
}
if major.is_some() || minor.is_some() || !replaced {
next.name = name.to_string();
}
}
if given.is_none() {
if let Some(face) = major {
next.major.latin = face.to_string();
if minor.is_none() {
next.minor.latin = face.to_string();
}
}
if let Some(face) = minor {
next.minor.latin = face.to_string();
}
next.name = "Custom".into();
} else {
if let Some(face) = major {
next.major.latin = face.to_string();
}
if let Some(face) = minor {
next.minor.latin = face.to_string();
}
}
Ok(next)
}
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?;
let scheme = builtin_format_schemes()
.into_iter()
.find(|f| f.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?;
let stored = scheme.name.clone();
let same = {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme)
};
if same {
return Ok(json!({"name": stored}));
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme;
Ok(())
})?;
Ok(json!({"name": stored}))
}
+501
View File
@@ -0,0 +1,501 @@
use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
};
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
usize_param(p, "layout")
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
Some(l) => Target::Layout { master: mi, layout: l },
None => Target::Master { master: mi },
};
s.select(|_, sel| {
sel.target = target;
sel.shapes.clear();
sel.text = None;
})?;
Ok(serde_json::to_value(target).unwrap_or_default())
}
pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) }
}
/// The four handout-master placeholders PowerPoint offers, in a page of `size`.
pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
let (w, h) = (size.width, size.height);
let (name, idx, x, y) = match kind {
PhType::Header => ("Header Placeholder", 0, 0.0, 0.0),
PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0),
PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95),
_ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95),
};
let mut text = TextBody::default();
if let Some(p) = text.paragraphs.first_mut()
&& matches!(kind, PhType::Date | PhType::SlideNum)
{
p.props.align = Some(deckcraft_model::text::Align::Right);
}
Shape {
id,
name: format!("{name} {}", idx + 1),
xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)),
ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }),
text: Some(text),
..Default::default()
}
}
const HANDOUT_KINDS: [(&str, PhType); 4] =
[("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)];
pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result<Value> {
let created = s.doc()?.doc.handout_master.is_none();
if created {
s.edit(|doc, _| {
let mut m = deckcraft_model::Master {
name: "Handout Master".into(),
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
..Default::default()
};
let size = doc.notes_size;
for (_, k) in HANDOUT_KINDS {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?);
m.shapes.push(handout_placeholder(k, size, id));
}
doc.handout_master = Some(Arc::new(m));
Ok(())
})?;
}
s.select(|_, sel| {
sel.target = Target::Handout;
sel.shapes.clear();
sel.text = None;
})?;
Ok(json!({"created": created}))
}
pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, _| {
let size = doc.notes_size;
let mut ids = vec![];
for (key, _) in HANDOUT_KINDS {
let id = match p.get(key).and_then(Value::as_bool) {
Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)),
None => None,
};
ids.push(id);
}
let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?);
for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) {
let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue };
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
if on && !has {
m.shapes.push(handout_placeholder(kind, size, id));
} else if !on {
m.shapes.retain(|x| x.ph_type() != Some(kind));
}
}
Ok(())
})?;
let m = s.doc()?.doc.handout_master.clone();
let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k)));
Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)}))
}
pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) }
}
const NOTES_KINDS: [(&str, PhType); 6] = [
("header", PhType::Header),
("date", PhType::Date),
("slideImage", PhType::SlideImage),
("body", PhType::Body),
("footer", PhType::Footer),
("pageNumber", PhType::SlideNum),
];
/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master.
pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
let (w, h) = (size.width, size.height);
match kind {
PhType::SlideImage => Shape {
id,
name: "Slide Image Placeholder 3".into(),
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)),
ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }),
fill: Some(Fill::None),
line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)),
..Default::default()
},
PhType::Body => {
let mut text = TextBody::default();
text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")];
Shape {
id,
name: "Notes Placeholder 4".into(),
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)),
ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }),
text: Some(text),
..Default::default()
}
}
_ => {
let mut s = handout_placeholder(kind, size, id);
if let Some(ph) = s.ph.as_mut() {
ph.idx = match kind {
PhType::Header => 0,
PhType::Date => 1,
PhType::Footer => 4,
_ => 5,
};
}
s
}
}
}
pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result<Value> {
let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty());
if created {
s.edit(|doc, _| {
let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master {
name: "Notes Master".into(),
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
..Default::default()
});
let size = doc.notes_size;
for (_, k) in NOTES_KINDS {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?);
m.shapes.push(notes_placeholder(k, size, id));
}
doc.notes_master = Some(Arc::new(m));
Ok(())
})?;
}
s.select(|_, sel| {
sel.target = Target::NotesMaster;
sel.shapes.clear();
sel.text = None;
})?;
Ok(json!({"created": created}))
}
pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, _| {
let size = doc.notes_size;
let ids: Vec<ShapeId> =
NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::<Result<_>>()?;
let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?);
for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) {
let Some(on) = p.get(key).and_then(Value::as_bool) else { continue };
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
if on && !has {
m.shapes.push(notes_placeholder(kind, size, id));
} else if !on {
m.shapes.retain(|x| x.ph_type() != Some(kind));
}
}
Ok(())
})?;
let m = s.doc()?.doc.notes_master.clone();
let mut out = serde_json::Map::new();
for (key, kind) in NOTES_KINDS {
out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind)))));
}
Ok(Value::Object(out))
}
pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
s.select(|_, sel| {
sel.target = Target::Slides;
sel.shapes.clear();
sel.text = None;
})?;
ok()
}
pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Custom Layout").to_string();
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let size = doc.slide_size;
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?);
let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| {
t.id = tid;
t.text = Some(TextBody::default());
t
});
let _ = size;
let at = match sel.target {
Target::Layout { layout, .. } => layout + 1,
_ => m.layouts.len(),
};
m.layouts.insert(
at.min(m.layouts.len()),
deckcraft_model::Layout {
id,
name,
kind: deckcraft_model::LayoutType::Custom,
shapes: title.into_iter().collect(),
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
},
);
sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) };
Ok(json!({"id": id}))
})
}
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.doc()?;
let (m, l) = match st.selection.target {
Target::Layout { master, layout } => (master, layout),
_ => (master_index(&st.doc, &st.selection), 0),
};
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
}
pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
s.edit(|doc, _| {
let l =
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?;
l.name = name;
Ok(())
})?;
ok()
}
pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
s.edit(|doc, sel| {
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
if doc.slides.iter().any(|x| x.layout == id) {
return Err(bad("master.deleteLayout", "slides use this layout"));
}
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?);
if m.layouts.len() <= 1 {
return Err(bad("master.deleteLayout", "a master needs at least one layout"));
}
m.layouts.remove(li);
sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) };
Ok(())
})?;
ok()
}
pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let kind = match str_param(p, "kind").unwrap_or("content") {
"text" => PhType::Body,
"picture" => PhType::Picture,
"chart" => PhType::Chart,
"table" => PhType::Table,
"media" => PhType::Media,
_ => PhType::Obj,
};
if !matches!(s.doc()?.selection.target, Target::Layout { .. }) {
return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view"));
}
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4));
let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1;
let shape = Shape {
xfrm: Some(rect),
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
text: Some(TextBody::default()),
..Default::default()
};
let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?;
Ok(json!({"id": id}))
}
pub(super) fn preserve(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let (on_master, mi, li, current) = match st.selection.target {
Target::Master { master } => {
let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?;
(true, master, 0, on)
}
Target::Layout { master, layout } => {
let on = st
.doc
.masters
.get(master)
.and_then(|m| m.layouts.get(layout))
.map(|l| l.preserve)
.ok_or_else(|| bad("master.preserve", "no such layout"))?;
(false, master, layout, on)
}
Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")),
};
let next = bool_param(p, "preserve").unwrap_or(!current);
if next == current {
return Ok(json!({"preserve": next}));
}
s.edit(|doc, _| {
if on_master {
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?);
m.preserve = next;
} else {
let l =
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?;
l.preserve = next;
}
Ok(())
})?;
Ok(json!({"preserve": next}))
}
pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result<Value> {
let title = bool_param(p, "title");
let footers = bool_param(p, "footers");
if title.is_none() && footers.is_none() {
return Err(bad("master.placeholders", "give title or footers"));
}
let st = s.doc()?;
if matches!(st.selection.target, Target::Slides) {
return Err(bad("master.placeholders", "open Slide Master view"));
}
let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
let title_changes = title.is_some_and(|want| want != has_title);
let footer_changes = footers.is_some_and(|want| want != has_footers);
if !title_changes && !footer_changes {
return Ok(json!({"title": has_title, "footers": has_footers}));
}
let mut add = Vec::new();
if title == Some(true) && !has_title {
add.push(PhType::Title);
}
if footers == Some(true) && !has_footers {
add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]);
}
let size = st.doc.slide_size;
s.edit(|doc, sel| {
let mut ids = Vec::with_capacity(add.len());
for _ in 0..add.len() {
ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?));
}
let prototypes = match sel.target {
Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(),
Target::Slides => Vec::new(),
Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")),
};
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?;
if title == Some(false) {
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
}
if footers == Some(false) {
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind));
}
for (kind, id) in add.into_iter().zip(ids) {
let idx = free_ph_idx(shapes, preferred_ph_idx(kind));
let proto = prototypes.iter().find(|sh| match kind {
PhType::Title => sh.ph_type().is_some_and(PhType::is_title),
other => sh.ph_type() == Some(other),
});
let shape = match proto {
Some(src) => retarget_placeholder(src.clone(), id, idx),
None => synthesized_placeholder(kind, id, idx, size),
};
shapes.push(shape);
}
Ok(())
})?;
let st = s.doc()?;
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
Ok(json!({"title": title, "footers": footers}))
}
fn preferred_ph_idx(kind: PhType) -> u32 {
match kind {
PhType::Title | PhType::CtrTitle => 0,
PhType::Date => 10,
PhType::Footer => 11,
PhType::SlideNum => 12,
_ => 1,
}
}
fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 {
let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx));
if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) }
}
/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide
/// number keeps its `‹#›` field.
fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape {
sh.id = id;
if let Some(ph) = sh.ph.as_mut() {
ph.idx = idx;
}
let slide_num = sh.ph_type() == Some(PhType::SlideNum);
if slide_num {
let paras = vec![slide_number_paragraph()];
if let Some(text) = sh.text.as_mut() {
text.paragraphs = paras;
} else {
sh.text = Some(TextBody { paragraphs: paras, ..Default::default() });
}
} else if let Some(text) = sh.text.as_mut() {
text.paragraphs.clear();
}
sh
}
fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape {
let (name, text) = match kind {
PhType::Date => ("Date Placeholder", TextBody::default()),
PhType::Footer => ("Footer Placeholder", TextBody::default()),
PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }),
_ => ("Title", TextBody::default()),
};
Shape {
id,
name: name.into(),
xfrm: Some(placeholder_rect(kind, size)),
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
text: Some(text),
..Default::default()
}
}
/// Standard placeholder boxes at 960×540, scaled to the slide size.
fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm {
let sx = size.width / 960.0;
let sy = size.height / 540.0;
let (x, y, w, h) = match kind {
PhType::Date => (60.0, 500.0, 216.0, 28.0),
PhType::Footer => (318.0, 500.0, 324.0, 28.0),
PhType::SlideNum => (684.0, 500.0, 216.0, 28.0),
_ => (60.0, 29.0, 840.0, 104.0),
};
deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy)
}
fn slide_number_paragraph() -> deckcraft_model::Paragraph {
deckcraft_model::Paragraph {
runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }],
..Default::default()
}
}
@@ -0,0 +1,301 @@
use super::{
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
f64_param, ok, usize_param, with_param,
};
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
let color = color_param(p, "color");
let style = usize_param(p, "style");
let op = if reset {
BgOp::Set(None)
} else if let Some(mut c) = color {
if let Some(t) = transparency {
set_solid_transparency(&mut c, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
} else if let Some(i) = style {
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
let slots =
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
let i = i.clamp(1, 12) - 1;
BgOp::Set(Some(Background::Ref {
idx: 1001 + (i / 4) as u32,
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
}))
} else if let Some(g) = p.get("gradient") {
let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
g.rotate_with_shape = false;
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
} else if let Some(pt) = p.get("pattern") {
BgOp::Set(Some(Background::Fill {
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
}),
}))
} else if p.get("picture").is_some() {
let (name, bytes) =
crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
let ct = crate::cmd::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?;
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
if let Some(pp) = &props {
apply_picture_props(&mut pf, pp);
}
if let Some(t) = transparency {
set_picture_transparency(&mut pf, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
} else if props.is_some() || transparency.is_some() {
BgOp::Adjust
} else {
return Err(bad(
"design.background",
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
));
};
let all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
let current = background_of(&st.doc, &st.selection, index);
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
if props.is_some() && !picture {
return Err(bad("design.background", "picture options need a picture background"));
}
if transparency.is_some() && !picture && !solid {
return Err(bad("design.background", "transparency needs a solid or picture background"));
}
let mut next = current.clone();
if let Some(bg) = next.as_mut() {
adjust_background(bg, props.as_ref(), transparency);
}
if next == current { None } else { Some(next) }
};
let Some(next) = next else { return ok() };
s.edit(|doc, sel| {
write_background(doc, sel, index, false, next);
Ok(())
})?;
return ok();
}
let BgOp::Set(bg) = op else { return ok() };
let unchanged = {
let st = s.doc()?;
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
};
if unchanged {
return ok();
}
s.edit(|doc, sel| {
write_background(doc, sel, index, all, bg);
Ok(())
})?;
ok()
}
enum BgOp {
Set(Option<Background>),
Adjust,
}
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
match sel.target {
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
}
}
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
let fallback = (!doc.masters.is_empty()).then_some(0);
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
}
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
match sel.target {
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
if all && bg.is_some() {
let masters = used_master_indices(doc);
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
let layouts_match = !masters.is_empty()
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
slides_match && masters_match && layouts_match
} else {
slides_match
}
}
}
}
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
match sel.target {
Target::Master { master } => {
if let Some(m) = doc.masters.get_mut(master) {
Arc::make_mut(m).background = bg;
}
}
Target::Layout { master, layout } => {
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
l.background = bg;
}
}
t @ (Target::Handout | Target::NotesMaster) => {
if let Some(m) = crate::page_master_mut(doc, t) {
Arc::make_mut(m).background = bg;
}
}
Target::Slides => {
if all && bg.is_some() {
for idx in used_master_indices(doc) {
if let Some(m) = doc.masters.get_mut(idx) {
let m = Arc::make_mut(m);
m.background = bg.clone();
for l in &mut m.layouts {
l.background = None;
}
}
}
}
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).background = bg.clone();
}
}
}
}
}
fn copy_slide_background(s: &mut Session) -> Result<Value> {
let bg = {
let st = s.doc()?;
if !matches!(st.selection.target, Target::Slides) {
return Err(bad("design.background", "copy applies to slides"));
}
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
let masters_match = if bg.is_some() {
let masters = used_master_indices(&st.doc);
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
} else {
true
};
if slides_match && masters_match {
return ok();
}
bg
};
s.edit(|doc, sel| {
write_background(doc, sel, None, true, bg);
Ok(())
})?;
ok()
}
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
match bg {
Background::Fill { fill: Fill::Picture(pf) } => {
if let Some(pp) = props {
apply_picture_props(pf, pp);
}
if let Some(t) = transparency {
set_picture_transparency(pf, t);
}
}
Background::Fill { fill: Fill::Solid { color } } => {
if let Some(t) = transparency {
set_solid_transparency(color, t);
}
}
_ => {}
}
}
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
let a = 1.0 - t.clamp(0.0, 1.0);
pf.alpha = (a < 1.0).then_some(a);
}
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
let t = t.clamp(0.0, 1.0);
if t > 0.0 {
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
}
}
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
use deckcraft_model::style::PictureMode;
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
}
}
pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, sel| {
if let Some(sl) = doc.slides.get_mut(sel.slide) {
let sl = Arc::make_mut(sl);
sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes);
}
Ok(())
})?;
ok()
}
+339
View File
@@ -0,0 +1,339 @@
use super::{
Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param,
json, ok, str_param,
};
pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) {
for sh in shapes {
if let Some(x) = sh.xfrm.as_mut() {
x.x = x.x * sx + ox;
x.y = x.y * sy + oy;
x.w *= sx;
x.h *= sy;
}
if let Some(t) = sh.text.as_mut() {
deckcraft_model::edit::format_all(t, &|r| {
if let Some(sz) = r.size.as_mut() {
*sz = (*sz * k).max(1.0);
}
});
}
}
}
pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result<Value> {
let size = if let Some(pre) = str_param(p, "preset") {
let k = pre.to_ascii_lowercase();
let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k));
let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?;
Size::new(*w, *h)
} else {
Size::new(
f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0),
f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0),
)
};
let size = match str_param(p, "orientation") {
Some("portrait") if size.width > size.height => Size::new(size.height, size.width),
Some("landscape") if size.height > size.width => Size::new(size.height, size.width),
Some("portrait" | "landscape") | None => size,
Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))),
};
let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string();
s.edit(|doc, _| {
let old = doc.slide_size;
let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
let (sx, sy, k, ox, oy) = match mode.as_str() {
"none" => (1.0, 1.0, 1.0, 0.0, 0.0),
"maximize" => {
let k = sx.max(sy);
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
}
_ => {
let k = sx.min(sy);
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
}
};
// Masters and layouts stretch to the new size; slides scale by mode.
let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
for m in &mut doc.masters {
let m = Arc::make_mut(m);
scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0);
for l in &mut m.layouts {
scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0);
}
}
for sl in &mut doc.slides {
scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy);
}
doc.slide_size = size;
Ok(())
})?;
Ok(json!({"w": size.width, "h": size.height}))
}
pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, sel| {
let hf = &mut doc.header_footer;
if let Some(v) = bool_param(p, "date") {
hf.date = v;
}
if let Some(v) = str_param(p, "dateText") {
hf.date_text = v.into();
}
if let Some(v) = str_param(p, "dateFormat") {
// Refused rather than ignored: a format that renders nothing would leave the
// placeholder showing its saved text for ever.
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
}
hf.date_format = v.into();
}
if let Some(v) = bool_param(p, "slideNumber") {
hf.slide_number = v;
}
if let Some(v) = bool_param(p, "footer") {
hf.footer = v;
}
if let Some(v) = str_param(p, "footerText") {
hf.footer_text = v.into();
hf.footer = hf.footer || !v.is_empty();
}
if let Some(v) = bool_param(p, "hideOnTitle") {
hf.hide_on_title = v;
}
let hf = hf.clone();
let date_given = str_param(p, "dateText").is_some();
let format_given = str_param(p, "dateFormat").is_some();
let all = bool_or(p, "all", true);
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
for i in list {
let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue };
let lay = doc.layout(layout).map(|(_, l)| l.clone());
let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title);
let want = |k: PhType| {
!(hf.hide_on_title && is_title)
&& match k {
PhType::Date => hf.date,
PhType::SlideNum => hf.slide_number,
PhType::Footer => hf.footer,
_ => false,
}
};
let mut new_ids = vec![];
for _ in 0..3 {
new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?));
}
let Some(sl) = doc.slides.get_mut(i) else { continue };
let sl = Arc::make_mut(sl);
for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() {
let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k));
match (has, want(k)) {
(Some(pos), false) => {
sl.shapes.remove(pos);
}
(None, true) => {
let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned());
let Some(lp) = lp else { continue };
let body = match k {
PhType::SlideNum => TextBody {
paragraphs: vec![deckcraft_model::Paragraph {
runs: vec![Run {
text: "‹#›".into(),
props: Default::default(),
kind: RunKind::Field { field: "slidenum".into() },
}],
..Default::default()
}],
..Default::default()
},
PhType::Date if hf.date_text.is_empty() => TextBody {
paragraphs: vec![deckcraft_model::Paragraph {
runs: vec![Run {
text: date_now(&hf.date_format),
props: Default::default(),
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
}],
..Default::default()
}],
..Default::default()
},
PhType::Date => TextBody::from_text(&hf.date_text),
_ => TextBody::from_text(&hf.footer_text),
};
sl.shapes.push(Shape {
id: new_ids.get(n).copied().unwrap_or_default(),
name: lp.name.clone(),
ph: lp.ph.clone(),
text: Some(body),
..Default::default()
});
}
(Some(pos), true) if k == PhType::Footer => {
if let Some(sh) = sl.shapes.get_mut(pos) {
sh.text = Some(TextBody::from_text(&hf.footer_text));
}
}
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
if let Some(sh) = sl.shapes.get_mut(pos) {
set_date(sh, &hf.date_text, &hf.date_format);
}
}
_ => {}
}
}
}
Ok(())
})?;
ok()
}
/// The date field type `format` names, `datetime1` when it is empty or unknown.
fn date_kind(format: &str) -> &str {
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
}
/// The saved text of a date field of `format`, as it shows now.
fn date_now(format: &str) -> String {
let kind = date_kind(format);
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
}
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
/// `fixed`, at an automatic date field (one it already has keeps its format).
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
let field_now = runs().find_map(|r| match &r.kind {
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
_ => None,
});
let has_field = field_now.is_some();
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
let want = date_kind(format);
let (text, kind) = match fixed {
// Automatic: keep the field, but follow a format that has changed.
"" if field_now.as_deref() == Some(want) => return,
"" if has_field && format.is_empty() => return,
"" => (date_now(format), RunKind::Field { field: want.into() }),
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
_ => (fixed.to_string(), RunKind::Text),
};
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
}
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
/// field. Empty where there is no clock (wasm).
pub fn today() -> String {
date_field_text("datetime1")
}
/// What date field `kind` shows now (empty if unknown or there is no clock).
pub fn date_field_text(kind: &str) -> String {
use deckcraft_text::datetime::{DateTime, field_text};
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
}
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
pub fn civil(z: i64) -> (i64, u32, u32) {
use chrono::Datelike;
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
// 0..146_097 days from it always fits.
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1),
}
}
#[cfg(test)]
mod header_footer_date_tests {
use deckcraft_model::PhType;
use deckcraft_model::text::RunKind;
use serde_json::json;
use crate::Session;
/// The date field of the Date placeholders of every slide: (saved text, field type).
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
let d = &s.doc().unwrap().doc;
d.slides
.iter()
.flat_map(|sl| sl.shapes.iter())
.filter(|sh| sh.ph_type() == Some(PhType::Date))
.flat_map(|sh| sh.text.iter())
.flat_map(|t| t.paragraphs.iter())
.flat_map(|p| p.runs.iter())
.map(|r| {
let kind = match &r.kind {
RunKind::Field { field } => Some(field.clone()),
_ => None,
};
(r.text.clone(), kind)
})
.collect()
}
fn sample(kind: &str) -> String {
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
}
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
/// The clock is read before and after the command, so a second ticking over between the
/// command and the read cannot fail the comparison. Returns the run text.
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
let before = sample(kind);
s.execute("design.headerFooter", &params).unwrap();
let after = sample(kind);
let got = dates(s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some(kind));
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
got[0].0.clone()
}
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
/// existed for this and was never read: the placeholder was always `datetime1`, and a
/// `dateFormat` parameter was accepted and silently dropped.
#[test]
fn the_automatic_date_takes_the_format_it_is_given() {
let mut s = Session::with_new();
// Default: the short date, as before.
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
// A format: the field type changes and the saved text is that format's.
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
// A time format works the same way, and these two differ, so the format is really read.
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_ne!(time, sample("datetime5"));
// Asking again changes nothing (and does not rewrite the run).
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_eq!(again, time);
// A fixed date still wins: plain text, no field.
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
// Back to automatic: the remembered format comes back, not datetime1.
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
// An unknown format is refused rather than ignored, and changes nothing.
for bad in ["datetime0", "datetime14", "nonsense", ""] {
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
let got = dates(&s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
}
}
+487
View File
@@ -0,0 +1,487 @@
use std::sync::Arc;
use deckcraft_color::SchemeSlot;
use deckcraft_model::style::PictureMode;
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
use deckcraft_model::{Background, Fill};
use serde_json::{Value, json};
use crate::{Session, UiRequest};
fn scheme(s: &Session) -> deckcraft_color::ColorScheme {
s.doc().unwrap().doc.masters[0].theme.colors.clone()
}
fn font_scheme(s: &Session) -> FontScheme {
s.doc().unwrap().doc.masters[0].theme.fonts.clone()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn png() -> String {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
crate::cmd::base64_encode(&bytes)
}
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
other => panic!("expected a picture background, got {other:?}"),
}
}
#[test]
fn layout_commands_reject_invalid_index() {
let mut s = Session::with_new();
s.execute("master.insertLayout", &json!({})).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
let n = count(&s);
let before = names(&s);
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
assert_eq!(count(&s), n);
assert_eq!(names(&s), before);
}
s.execute("master.deleteLayout", &json!({})).unwrap();
assert_eq!(count(&s), n - 1);
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
assert_eq!(super::civil(20_734), (2026, 10, 8));
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
fn colors_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = scheme(&s);
let n = undo_len(&s);
s.execute("design.colors", &json!({})).unwrap();
s.execute("design.colors", &Value::Null).unwrap();
assert_eq!(scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 2);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeColors");
assert_eq!(params["name"], "DeckCraft");
let colors = params["colors"].as_object().unwrap();
assert_eq!(colors.len(), 12);
assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex()));
assert!(colors.contains_key("folHlink"));
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap();
assert_eq!(scheme(&s).name, "Harbor");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
colors_dialog_rejects(&mut s, n, &harbor);
}
fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no colour scheme `Nope`"), "{err}");
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
assert!(s.execute("design.colors", &json!({"name": 1})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
assert_eq!(scheme(s).name, "Harbor");
assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(s), n + 1);
colors_dialog_custom_overrides(s, n, harbor);
}
fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
assert_eq!(scheme(s).name, "Custom");
assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233");
assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
assert_eq!(undo_len(s), n + 2);
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
assert_eq!(scheme(s).name, "Meadow");
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
assert_eq!(undo_len(s), n + 3);
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
assert_eq!(scheme(s).name, "Talk");
assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(scheme(s).name, "Meadow");
assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
}
#[test]
fn fonts_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = font_scheme(&s);
let n = undo_len(&s);
s.execute("design.fonts", &json!({})).unwrap();
s.execute("design.fonts", &Value::Null).unwrap();
s.execute("design.fonts", &json!([1, 2])).unwrap();
assert_eq!(font_scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 3);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeFonts");
assert_eq!(params["name"], original.name);
assert_eq!(params["major"], original.major.latin);
assert_eq!(params["minor"], original.minor.latin);
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.fonts", &json!({"name": "classic"})).unwrap();
let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap();
assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name");
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("design.fonts", &json!({"name": "Classic"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no font scheme `Nope`"), "{err}");
assert!(s.execute("design.fonts", &json!({"name": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"name": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"major": ""})).is_err());
assert!(s.execute("design.fonts", &json!({"major": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
assert_eq!(font_scheme(&s), classic);
assert_eq!(undo_len(&s), n + 1);
fonts_dialog_custom_overrides(&mut s);
}
fn fonts_dialog_custom_overrides(s: &mut Session) {
s.edit(|doc, _| {
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
fonts.major.ea = "EA Major".into();
fonts.minor.cs = "CS Minor".into();
fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() });
Ok(())
})
.unwrap();
let marked = font_scheme(s);
let n = undo_len(s);
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
let custom = font_scheme(s);
assert_eq!(custom.name, "Custom");
assert_eq!(custom.major.latin, "Lato");
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
assert_eq!(custom.major.ea, "EA Major");
assert_eq!(custom.minor.cs, "CS Minor");
assert_eq!(custom.major.scripts, marked.major.scripts);
assert_eq!(undo_len(s), n + 1);
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
assert_eq!(font_scheme(s).name, "Custom");
assert_eq!(font_scheme(s).major.latin, "Lato");
assert_eq!(font_scheme(s).minor.latin, "Roboto");
assert_eq!(font_scheme(s).major.ea, "EA Major");
assert_eq!(undo_len(s), n + 2);
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
assert_eq!(font_scheme(s).name, "Classic");
assert_eq!(font_scheme(s).major.latin, "Inter");
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
assert_eq!(font_scheme(s).major.ea, "");
assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
assert_eq!(undo_len(s), n + 3);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
Ok(())
})
.unwrap();
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
assert_eq!(font_scheme(s).name, "Talk");
assert_eq!(font_scheme(s).major.latin, "Inter");
assert_eq!(font_scheme(s).minor.latin, "Nunito Sans");
assert_eq!(font_scheme(s).major.ea, "Kept");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(font_scheme(s).name, "Classic");
assert_eq!(font_scheme(s).major.ea, "Kept");
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
}
#[test]
fn background_picture_options_tile_transparency_and_copy() {
let mut s = Session::with_new();
let n = undo_len(&s);
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
assert!(err.contains("picture background"), "{err}");
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
other => panic!("expected a solid background, got {other:?}"),
}
let n = undo_len(&s);
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
let pf = picture(&s);
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
let n = undo_len(&s);
s.execute(
"design.background",
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
)
.unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
assert_eq!(flip, "xy");
assert_eq!(align, "ctr");
}
other => panic!("expected a tile, got {other:?}"),
}
assert!(picture(&s).upright);
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, align, .. } => {
assert_eq!(tx, 12.0);
assert_eq!(align, "ctr");
}
other => panic!("expected the tile to stay, got {other:?}"),
}
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
match picture(&s).mode {
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
other => panic!("expected a stretch, got {other:?}"),
}
s.execute("slide.new", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
}
#[test]
fn background_all_applies_to_master_and_new_slides_inherit() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0["background"], "#121826");
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
// Slide 0 has new background, master has new background, layouts cleared
let doc = &s.doc().unwrap().doc;
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
assert_eq!(doc.masters[0].background, doc.slides[0].background);
for l in &doc.masters[0].layouts {
assert!(l.background.is_none());
}
// Newly added slides inherit the master background (fixes #891)
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
assert_eq!(s0_after["background"], "#0D0D0D");
assert_eq!(s1_after["background"], "#0D0D0D");
// Undo restores previous slide and master backgrounds
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0_reverted["background"], "#121826");
}
#[test]
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
// Slide 0 follows master (background: None)
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
// Give Slide 1 an explicit override
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Select slide 0 (which follows master) and apply copy to all
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slide 1 has been reset to None (follows master), slide 0 remains None
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-applying copy is a no-op
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
}
#[test]
fn background_reset_all_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Set explicit backgrounds on both slides individually (master remains untouched)
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
// Now perform reset with all
let n = undo_len(&s);
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slides reset to None to follow master
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-running reset all is a no-op
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
}
#[test]
fn background_all_only_touches_used_masters() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Add a second master to presentation
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
s.edit(|doc, _| {
let mut m2 = (*doc.masters[0]).clone();
m2.id = deckcraft_model::MasterId(999);
m2.background = Some(second_master_bg.clone());
doc.masters.push(Arc::new(m2));
Ok(())
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
// Slides only use master 0 (default layout)
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
let doc = &s.doc().unwrap().doc;
// Master 0 updated
assert_eq!(doc.masters[0].background, doc.slides[0].background);
// Master 1 preserved!
assert_eq!(doc.masters[1].background, Some(second_master_bg));
}
#[test]
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Set all slides and master to #AA0000
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
// Add a layout override on layout 0
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_bg);
Ok(())
})
.unwrap();
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
let doc = &s.doc().unwrap().doc;
assert!(doc.masters[0].layouts[0].background.is_none());
// Now that layout override is cleared, repeating is an actual no-op
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
+83 -70
View File
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
richclip, str_param, targets, xfrm_of,
};
use crate::{HistoryEntry, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -44,14 +47,19 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn restore(s: &mut Session, redo: bool) -> Result<Value> {
let vertex_kind = s.tool.vertex_kind.clone();
let st = s.doc_mut()?;
let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
let Some(e) = from.pop() else { return ok() };
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
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;
// The point type Edit Points remembered with the restored document: handle drags follow it.
s.tool.vertex_kind = e.vertex_kind;
Ok(json!({"label": e.label}))
}
@@ -67,30 +75,6 @@ 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())
}
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),
deckcraft_model::ShapeKind::Media(m) => {
out.push(m.media);
if let Some(p) = m.poster {
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 {
out.push(pf.media);
}
});
}
fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
let st = s.doc()?;
// Text selection.
@@ -111,16 +95,18 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();
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);
}
mids.extend(crate::media_refs::slide_media(sl));
}
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");
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
// Selected table cells copy as their text: a tab between cells and a paragraph per row.
if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
.selection
.shapes
@@ -134,8 +120,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
let mut mids = vec![];
media_of_shapes(&shapes, &mut mids);
let mids = crate::media_refs::shapes_media(&shapes);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
@@ -248,32 +233,13 @@ fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
rec(shapes, f, 0);
}
/// 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| {
/// Rewrite a media id the clipboard used (`map`: clipboard id to destination id).
fn remap_media(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
move |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
@@ -282,7 +248,11 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck
let mut map = HashMap::new();
for m in media {
match doc.media(m.id) {
None => doc.media.push(m.clone()),
None => {
// Keeps the foreign id, so later ids must start past it.
doc.media.push(m.clone());
doc.next_id = doc.next_id.max(m.id.0);
}
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))?;
@@ -326,12 +296,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
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;
}
crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));
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();
}
@@ -355,8 +320,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
reid("edit.paste", doc, &mut shapes)?;
remap_media(&mut shapes, &media);
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let mut taken = super::current_names(doc, sel);
for sh in &mut shapes {
// Only a name already on the slide is changed (two shapes with one name are what the
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
if taken.contains(&sh.name)
&& let Some((base, n)) = sh.name.rsplit_once(' ')
&& !n.is_empty()
&& n.bytes().all(|b| b.is_ascii_digit())
{
sh.name = super::insert::first_free_name(base, &taken);
}
taken.insert(sh.name.clone());
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
x.y += off;
@@ -502,7 +478,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
let media = bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
crate::media_refs::visit_mut::shapes(&mut clip.shapes, &mut remap_media(&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"))?;
@@ -534,15 +511,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();
// 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;
@@ -555,8 +548,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> {
@@ -566,18 +562,35 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
return s.execute("slide.delete", &json!({}));
}
// With table cells selected, Delete clears their text and keeps the table, as in PowerPoint.
let sel = &s.doc()?.selection;
if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) {
return super::table::clear_selected_cells(s);
}
let ids = targets(s, p)?;
if ids.is_empty() {
return ok();
}
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;
+58 -6
View File
@@ -1,9 +1,13 @@
//! 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::*;
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -33,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
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|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!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|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",
@@ -156,14 +160,17 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_pptx::sniff(bytes) {
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut 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 rtf = deckcraft_format::rtf::sniff_rtf(bytes);
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
let text = String::from_utf8_lossy(bytes);
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
if lower.ends_with(".md") {
deckcraft_format::markdown_to_slides(&mut p, &text);
@@ -178,6 +185,48 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
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);
}
}
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
pub fn outline_text(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
}
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);
@@ -293,6 +342,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
match format {
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()),
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
@@ -368,7 +418,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
}
/// 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"];
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
/// 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
@@ -401,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
"gif"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else if l.ends_with(".rtf") {
"rtf"
} else {
"deckcraft"
}
@@ -581,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
}))
}
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
let bytes = save_bytes(&st.doc, &format)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
+32 -4
View File
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -105,7 +105,7 @@ impl Opts {
}
fn para_chars(p: &Paragraph) -> Vec<char> {
p.runs.iter().flat_map(|r| r.text.chars()).collect()
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).
@@ -113,7 +113,7 @@ 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.text.chars().count();
let n = r.char_len();
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
off += n;
}
@@ -125,7 +125,7 @@ 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.text.chars().collect();
let chars: Vec<char> = r.content().chars().collect();
let (s, e) = (off, off + chars.len());
off = e;
if e <= a || s >= b {
@@ -222,6 +222,34 @@ mod tests {
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() };
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
};
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, current_run(s).bold);
format_run(s, &move |r| r.bold = Some(v))
}
pub(super) fn italic(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, current_run(s).italic);
format_run(s, &move |r| r.italic = Some(v))
}
pub(super) fn underline(s: &mut Session, p: &Value) -> Result<Value> {
let cur = current_run(s).underline.is_some_and(|u| u != "none");
let v = on_param(p, Some(cur));
let style = str_param(p, "style").unwrap_or("sng").to_string();
format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() }))
}
pub(super) fn strike(s: &mut Session, p: &Value) -> Result<Value> {
let cur = current_run(s).strike.is_some_and(|x| x != Strike::None);
let v = on_param(p, Some(cur));
let d = bool_or(p, "double", false);
format_run(s, &move |r| {
r.strike = Some(if !v {
Strike::None
} else if d {
Strike::Double
} else {
Strike::Single
})
})
}
pub(super) fn superscript(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0));
format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 }))
}
pub(super) fn subscript(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0));
format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 }))
}
pub(super) fn font(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string();
format_run(s, &move |r| r.font = Some(f.clone()))
}
pub(super) fn size(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0);
format_run(s, &move |r| r.size = Some(v))
}
/// PowerPoint's font size list (grow/shrink steps through it).
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
fn step(cur: f64, up: bool) -> f64 {
if up {
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
} else {
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
}
}
fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
// Each run steps from its own size, like PowerPoint.
let st = s.doc()?;
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st;
let base = ctx_sizes;
format_run(s, &move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
})
}
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, true)
}
pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, false)
}
pub(super) fn color(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
// which used to answer and change nothing.
let st = s.doc()?;
if st.selection.text.is_none() {
let charts: Vec<ShapeId> =
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
let only_charts = charts.len() == st.selection.shapes.len();
if !charts.is_empty() {
let tc = c.clone();
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
if let ShapeKind::Chart(ch) = &mut sh.kind {
ch.text_color = Some(tc.clone());
ch.raw = None;
}
Ok(())
})?;
if only_charts {
return ok();
}
}
}
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
}
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
fn with_transparency(c: &mut ColorRef, transparency: f64) {
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if transparency > 0.0 {
c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32));
}
}
/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own
/// fill takes the colour it shows now (from the placeholder, master or theme) so only its
/// opacity changes.
pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?;
if !v.is_finite() {
return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1"));
}
let v = v.clamp(0.0, 1.0);
let shown = match current_run(s).fill {
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f,
_ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)),
};
format_run(s, &move |r| {
let mut f = match &r.fill {
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(),
Some(Fill::None) => return,
_ => shown.clone(),
};
match &mut f {
Fill::Solid { color } => with_transparency(color, v),
Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)),
_ => {}
}
r.fill = Some(f);
})
}
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
let lang = str_param(p, "lang").map(str::to_string);
let no_proof = bool_param(p, "noProof");
if lang.is_none() && no_proof.is_none() {
return Err(bad(id, "give `lang` or `noProof`"));
}
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
return Err(bad(id, "`lang` is a language tag such as en-US"));
}
Ok(move |r: &mut RunProps| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
if let Some(n) = no_proof {
r.no_proof = n.then_some(true);
}
})
}
/// Review › Language on the selected text (or the selected shapes' text).
pub(super) fn language(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.language", p)?;
format_run(s, &f)
}
/// Every run in the deck, and the text styles new text inherits from, in one language.
pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.languageAll", p)?;
/// Runs, end marks and the body's own list style levels (never creating one).
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
ed::format_all(t, f);
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
if depth > 64 {
return;
}
for sh in list {
if let Some(t) = &mut sh.text {
body(t, f);
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
_ => {}
}
}
}
/// A master or presentation text style's levels: what new text inherits.
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
// Level 1 always, so a deck without a default text style gets one.
if l.level(0).is_none() {
l.set(0, Default::default());
}
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
s.edit(|doc, _| {
for sl in &mut doc.slides {
let sl = Arc::make_mut(sl);
shapes(&mut sl.shapes, &f, 0);
body(&mut sl.notes, &f);
}
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
let m = Arc::make_mut(m);
shapes(&mut m.shapes, &f, 0);
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
}
styles(&mut doc.default_text_style, &f);
Ok(())
})?;
ok()
}
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
}
pub(super) fn caps(s: &mut Session, p: &Value) -> Result<Value> {
let c = match str_param(p, "caps").unwrap_or("none") {
"small" => Caps::Small,
"all" => Caps::All,
_ => Caps::None,
};
format_run(s, &move |r| r.caps = Some(c))
}
pub(super) fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0);
format_run(s, &move |r| r.spacing = Some(v))
}
/// Character formatting applied together, so the Font dialog is one undo step.
struct FontEdits {
family: Option<String>,
size: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<String>,
color: Option<deckcraft_model::ColorRef>,
strike: Option<Strike>,
baseline: Option<f64>,
caps: Option<Caps>,
spacing: Option<f64>,
}
impl FontEdits {
fn is_empty(&self) -> bool {
self.family.is_none()
&& self.size.is_none()
&& self.bold.is_none()
&& self.italic.is_none()
&& self.underline.is_none()
&& self.color.is_none()
&& self.strike.is_none()
&& self.baseline.is_none()
&& self.caps.is_none()
&& self.spacing.is_none()
}
fn write(&self, r: &mut RunProps) {
if let Some(f) = &self.family {
r.font = Some(f.clone());
}
if let Some(v) = self.size {
r.size = Some(v);
}
if let Some(v) = self.bold {
r.bold = Some(v);
}
if let Some(v) = self.italic {
r.italic = Some(v);
}
if let Some(u) = &self.underline {
r.underline = Some(u.clone());
}
if let Some(c) = &self.color {
r.fill = Some(Fill::solid(c.clone()));
}
if let Some(v) = self.strike {
r.strike = Some(v);
}
if let Some(v) = self.baseline {
r.baseline = Some(v);
}
if let Some(v) = self.caps {
r.caps = Some(v);
}
if let Some(v) = self.spacing {
r.spacing = Some(v);
}
}
}
fn font_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))),
}
}
fn font_str<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
match p.get(key) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s)),
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))),
}
}
fn font_edits(p: &Value) -> Result<FontEdits> {
let family = match font_str(p, "family")? {
None => None,
Some(s) => {
let t = s.trim();
if t.is_empty() || t.chars().count() > 200 {
return Err(bad("format.fontDialog", "`family` must be 1–200 characters"));
}
Some(t.to_string())
}
};
let size = match p.get("size") {
None => None,
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?;
Some(n.clamp(1.0, 4000.0))
}
};
let underline = match font_str(p, "underline")? {
None => None,
Some(s) => {
if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") {
return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy"));
}
Some(s.to_string())
}
};
let color =
if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None };
let strike = match font_str(p, "strike")? {
None => None,
Some("none") => Some(Strike::None),
Some("sng" | "single") => Some(Strike::Single),
Some("dbl" | "double") => Some(Strike::Double),
Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")),
};
let baseline = match p.get("baseline") {
None => None,
Some(Value::String(s)) => match s.as_str() {
"none" => Some(0.0),
"super" | "superscript" => Some(0.3),
"sub" | "subscript" => Some(-0.25),
_ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
},
Some(Value::Number(n)) => {
let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?;
Some(f.clamp(-1.0, 1.0))
}
Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
};
let caps = match font_str(p, "caps")? {
None => None,
Some("none") => Some(Caps::None),
Some("small") => Some(Caps::Small),
Some("all") => Some(Caps::All),
Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")),
};
let spacing = match p.get("spacing") {
None => None,
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?;
Some(n.clamp(-100.0, 400.0))
}
};
Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing })
}
/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms.
fn color_command_value(c: &deckcraft_model::ColorRef) -> Value {
use deckcraft_model::ColorBase;
let mut o = serde_json::Map::new();
for m in &c.mods {
if let Some(v) = m.value() {
o.insert(m.xml_name().to_string(), json!(v));
}
}
match &c.base {
ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())),
ColorBase::Rgb { rgb } => {
o.insert("rgb".into(), json!(format!("#{}", rgb.hex())));
Value::Object(o)
}
ColorBase::Scheme { slot } => {
o.insert("scheme".into(), json!(slot.xml_name()));
Value::Object(o)
}
ColorBase::Preset { name } => json!(name),
ColorBase::System { last, .. } => json!(format!("#{}", last.hex())),
}
}
fn font_dialog_params(s: &Session) -> Value {
let r = current_run(s);
let family =
s.doc().ok().and_then(|st| crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default();
let baseline = r.baseline.unwrap_or(0.0);
let mut v = json!({
"family": family,
"size": r.size.unwrap_or(18.0),
"bold": r.bold.unwrap_or(false),
"italic": r.italic.unwrap_or(false),
"underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"),
"strike": match r.strike {
Some(Strike::Double) => "dbl",
Some(Strike::Single) => "sng",
_ => "none",
},
"baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" },
"caps": match r.caps {
Some(Caps::Small) => "small",
Some(Caps::All) => "all",
_ => "none",
},
"spacing": r.spacing.unwrap_or(0.0),
});
if let Some(Fill::Solid { color }) = &r.fill {
v["color"] = color_command_value(color);
}
v
}
pub(super) fn font_dialog(s: &mut Session, p: &Value) -> Result<Value> {
let edits = font_edits(p)?;
if edits.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) });
return ok();
}
format_run(s, &|r| edits.write(r))
}
pub(super) fn clear(s: &mut Session, _p: &Value) -> Result<Value> {
format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() })
}
+148
View File
@@ -0,0 +1,148 @@
use super::{
Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run,
ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param,
};
/// Formatting picked up by the Format Painter.
#[derive(Clone, Debug, Default)]
pub struct Painted {
pub run: Option<RunProps>,
pub para: Option<ParaProps>,
pub fill: Option<Fill>,
pub line: Option<Line>,
pub effects: Option<Effects>,
pub style: Option<deckcraft_model::ShapeStyle>,
}
pub(super) fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mut painted = Painted::default();
if let Some(t) = &st.selection.text {
if let Some(b) = crate::cmd::text::body_of(st, t) {
let a = t.ordered().0;
painted.run = Some(ed::props_at(b, (a.0, a.1 + 1)));
painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone());
}
} else if let Some(sh) = st.selected_shapes().first() {
painted.fill = sh.fill.clone();
painted.line = sh.line.clone();
painted.effects = sh.effects.clone();
painted.style = sh.style.clone();
painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone());
}
s.painter = Some((painted, bool_or(p, "sticky", false)));
Ok(json!({"armed": true}))
}
pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
if p.fill.is_some() || p.style.is_some() {
sh.fill.clone_from(&p.fill);
sh.line.clone_from(&p.line);
sh.effects.clone_from(&p.effects);
sh.style.clone_from(&p.style);
}
if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) {
ed::format_all(t, &|rp| *rp = r.clone());
}
}
/// The paragraphs the text selection covers whole: it starts at or before their start and
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
/// 0, as after a triple-click) is not covered.
fn covered_paragraphs(s: &Session) -> Vec<usize> {
let Ok(st) = s.doc() else { return vec![] };
let Some(t) = &st.selection.text else { return vec![] };
let Some(body) = crate::cmd::text::body_of(st, t) else { return vec![] };
let (a, b) = t.ordered();
// A hostile selection can point past the body: walk only the paragraphs it has.
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
.filter(|&pi| {
let Some(para) = body.paragraphs.get(pi) else { return false };
let starts = pi > a.0 || a.1 == 0;
let ends = if pi < b.0 {
true
} else if b.0 > a.0 && b.1 == 0 {
false
} else {
b.1 >= para.char_len()
};
starts && ends
})
.collect()
}
pub(super) fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
if !sticky {
s.painter = None;
}
if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() {
if let Some(r) = painted.run.clone() {
format_run(s, &move |rp| *rp = r.clone())?;
}
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
// selection covers whole; painting part of a bulleted line changes just its text.
let covered = covered_paragraphs(s);
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
crate::cmd::text::edit_text(s, |body, _| {
for &pi in &covered {
if let Some(para) = body.paragraphs.get_mut(pi) {
para.props = pp.clone();
}
}
})?;
}
return ok();
}
edit_shapes(s, p, "format.painterApply", |sh| {
apply_painted(sh, &painted);
Ok(())
})
}
pub(super) fn state(s: &mut Session, _p: &Value) -> Result<Value> {
let r = current_run(s);
let pp = current_para(s);
let st = s.doc()?;
let family = crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default();
Ok(json!({
"font": family,
"fontRef": r.font,
"size": r.size,
"bold": r.bold.unwrap_or(false),
"italic": r.italic.unwrap_or(false),
"underline": r.underline.as_deref().is_some_and(|u| u != "none"),
"strike": r.strike.is_some_and(|x| x != Strike::None),
"superscript": r.baseline.unwrap_or(0.0) > 0.0,
"subscript": r.baseline.unwrap_or(0.0) < 0.0,
"align": pp.align.map(|a| a.xml()),
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
"lang": r.lang,
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
"painter": s.painter.is_some(),
}))
}
/// Add a font from its bytes for this session (it then shows in the font menu and draws).
pub(super) fn font_add(_s: &mut Session, p: &Value) -> Result<Value> {
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?;
if data.len() > 64 << 20 {
return Err(bad("font.add", "font file larger than 64 MB"));
}
let faces = deckcraft_fonts::FontDb::global().add_font(data);
if faces == 0 {
return Err(bad("font.add", "not a font DeckCraft can read (or already added)"));
}
Ok(json!({"faces": faces}))
}
/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear.
pub(super) fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
let db = deckcraft_fonts::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
let faces = db.load_system_fonts();
#[cfg(target_arch = "wasm32")]
let faces = 0usize;
Ok(json!({"faces": faces, "families": db.families().len()}))
}
+416
View File
@@ -0,0 +1,416 @@
use super::{
Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param,
format_body, format_para, json, ok, on_param, resolve, str_param,
};
pub(super) fn align_to(s: &mut Session, a: Align) -> Result<Value> {
format_para(s, &move |p| p.props.align = Some(a))
}
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
let a = match str_param(p, "align").unwrap_or("left") {
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => Align::Left,
};
align_to(s, a)
}
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
/// at the start edge stays there.
pub(super) fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).rtl);
format_para(s, &move |para| {
if para.props.rtl.unwrap_or(false) != on {
para.props.align = match para.props.align {
None | Some(Align::Left) => Some(Align::Right),
Some(Align::Right) => Some(Align::Left),
other => other,
};
}
para.props.rtl = Some(on);
})
}
pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
let on = on_param(p, Some(cur));
let ch = str_param(p, "char").unwrap_or("•").to_string();
let col = color_param(p, "color");
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
let explicit_char = p.get("char").is_some();
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
if col.is_some() {
para.props.bullet_color = col.clone();
}
if size.is_some() {
para.props.bullet_size = size;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
para.props.indent = Some(-18.0);
}
} else {
para.props.bullet = Some(Bullet::None);
para.props.indent = Some(0.0);
para.props.margin_left = Some(36.0 * para.level as f64);
}
})
}
pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. }));
let on = on_param(p, Some(cur));
let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string();
let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1);
let col = color_param(p, "color");
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
if col.is_some() {
para.props.bullet_color = col.clone();
}
if size.is_some() {
para.props.bullet_size = size;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
para.props.indent = Some(-27.0);
} else {
para.props.bullet = Some(Bullet::None);
para.props.indent = Some(0.0);
para.props.margin_left = Some(36.0 * para.level as f64);
}
})
}
pub(super) fn level(s: &mut Session, d: i32) -> Result<Value> {
format_para(s, &move |p| {
let l = (p.level as i32 + d).clamp(0, 8) as u8;
// Explicit indents shift with the level.
if l != p.level {
if let Some(m) = p.props.margin_left.as_mut() {
*m = (*m + 36.0 * d as f64).max(0.0);
}
p.level = l;
}
})
}
pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let sp =
if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) };
format_para(s, &move |para| para.props.line_spacing = Some(sp))
}
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let a = str_param(p, "align").map(|a| match a {
"center" => Align::Center,
"right" => Align::Right,
"justify" => Align::Justify,
"distributed" => Align::Distributed,
_ => Align::Left,
});
let ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore");
let sa = f64_param(p, "spaceAfter");
let ls = f64_param(p, "lineSpacing");
let lsp = f64_param(p, "lineSpacingPt");
format_para(s, &move |para| {
if a.is_some() {
para.props.align = a;
}
if ml.is_some() {
para.props.margin_left = ml.map(|v| v.max(0.0));
}
if ind.is_some() {
para.props.indent = ind;
}
if let Some(v) = sb {
para.props.space_before = Some(Spacing::Pts(v.max(0.0)));
}
if let Some(v) = sa {
para.props.space_after = Some(Spacing::Pts(v.max(0.0)));
}
if let Some(v) = ls {
para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99)));
}
if let Some(v) = lsp {
para.props.line_spacing = Some(Spacing::Pts(v.max(0.0)));
}
})
}
const PARA_DIALOG_KEYS: &[&str] =
&["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"];
fn para_dialog_has_fields(p: &Value) -> bool {
PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some())
}
pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?;
Ok(Some(n))
}
}
}
pub(super) fn clamp_pt(v: f64) -> f64 {
v.clamp(0.0, 1584.0)
}
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
Ok(match s {
"left" | "l" => Align::Left,
"center" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
})
}
fn align_name(a: Align) -> &'static str {
match a {
Align::Left => "left",
Align::Center => "center",
Align::Right => "right",
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
}
}
fn spacing_parts(s: Option<Spacing>) -> (&'static str, f64) {
match s {
Some(Spacing::Pct(v)) => ("pct", v),
Some(Spacing::Pts(v)) => ("pts", v),
None => ("pts", 0.0),
}
}
fn paragraph_dialog_params(s: &Session) -> Value {
let pp = current_para(s);
let indent = pp.indent.unwrap_or(0.0);
let (special, by) = if indent > 0.05 {
("first", indent)
} else if indent < -0.05 {
("hanging", -indent)
} else {
("none", 0.0)
};
let (line_kind, line_value) = match pp.line_spacing {
Some(Spacing::Pts(v)) => ("exactly", v),
Some(Spacing::Pct(v)) => ("multiple", v),
None => ("multiple", 1.0),
};
let (before_kind, before) = spacing_parts(pp.space_before);
let (after_kind, after) = spacing_parts(pp.space_after);
let tabs: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
json!({
"align": align_name(pp.align.unwrap_or(Align::Left)),
"indentLeft": pp.margin_left.unwrap_or(0.0),
"indentRight": pp.margin_right.unwrap_or(0.0),
"special": special,
"specialBy": by,
"spaceBefore": before,
"spaceBeforeKind": before_kind,
"spaceAfter": after,
"spaceAfterKind": after_kind,
"lineKind": line_kind,
"lineValue": line_value,
"tabs": tabs,
"defaultTab": pp.default_tab.unwrap_or(72.0),
})
}
fn write_spacing(kind: Option<&str>, value: f64) -> Spacing {
if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) }
}
pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "format.paragraphDialog";
if !para_dialog_has_fields(p) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) });
return ok();
}
let align = match p.get("align") {
None => None,
Some(Value::String(a)) => Some(parse_align(CMD, a)?),
Some(_) => return Err(bad(CMD, "`align` must be a string")),
};
let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt);
let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt);
let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0)));
let indent = match p.get("special") {
None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)),
Some(Value::String(kind)) => {
let by = special_by.unwrap_or(0.0);
Some(match kind.as_str() {
"none" => 0.0,
"first" | "firstLine" => by,
"hanging" | "hang" => -by,
_ => return Err(bad(CMD, "`special` must be none, first or hanging")),
})
}
Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")),
};
let space_before = finite_key(p, CMD, "spaceBefore")?;
let space_after = finite_key(p, CMD, "spaceAfter")?;
let before_kind = str_param(p, "spaceBeforeKind");
let after_kind = str_param(p, "spaceAfterKind");
for kind in [before_kind, after_kind].into_iter().flatten() {
if !matches!(kind, "pts" | "pct") {
return Err(bad(CMD, "spacing kind must be pts or pct"));
}
}
let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99));
let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt);
format_para(s, &move |para| {
if align.is_some() {
para.props.align = align;
}
if indent_left.is_some() {
para.props.margin_left = indent_left;
}
if indent_right.is_some() {
para.props.margin_right = indent_right;
}
if indent.is_some() {
para.props.indent = indent;
}
if let Some(v) = space_before {
para.props.space_before = Some(write_spacing(before_kind, v));
}
if let Some(v) = space_after {
para.props.space_after = Some(write_spacing(after_kind, v));
}
if let Some(v) = line_mult {
para.props.line_spacing = Some(Spacing::Pct(v));
}
if let Some(v) = line_pt {
para.props.line_spacing = Some(Spacing::Pts(v));
}
})
}
pub(super) fn tab_align(s: &str) -> Option<&'static str> {
Some(match s {
"l" | "left" => "l",
"ctr" | "center" => "ctr",
"r" | "right" => "r",
"dec" | "decimal" => "dec",
_ => return None,
})
}
pub(super) fn tabs_params(s: &Session) -> Value {
let pp = current_para(s);
let stops: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)})
}
pub(super) fn anchor(s: &mut Session, p: &Value) -> Result<Value> {
let a = match str_param(p, "anchor").unwrap_or("top") {
"middle" | "center" | "ctr" => Anchor::Middle,
"bottom" | "b" => Anchor::Bottom,
_ => Anchor::Top,
};
format_body(s, &move |b| b.anchor = Some(a))
}
pub(super) fn direction(s: &mut Session, p: &Value) -> Result<Value> {
let d = match str_param(p, "dir").unwrap_or("horizontal") {
"rotate90" | "vert" => TextDir::Vertical,
"rotate270" | "vert270" => TextDir::Vertical270,
"stacked" => TextDir::Stacked,
_ => TextDir::Horizontal,
};
format_body(s, &move |b| b.vert = Some(d))
}
/// The edited or first selected shape's columns as drawn: the count and the spacing in points.
fn current_columns(s: &Session) -> (u32, f64) {
let Ok(st) = s.doc() else { return (1, 0.0) };
let id = match &st.selection.text {
Some(t) if !t.notes => Some(t.shape),
_ => st.selection.shapes.first().copied(),
};
let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) };
let body = match crate::cmd::text::ctx_for(&st.doc, &st.selection) {
Some(c) => resolve::body(&c, sh),
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
};
(body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0))
}
pub(super) fn columns(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "format.columns";
let count = match p.get("count") {
None => None,
Some(v) => {
let n = v
.as_f64()
.filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n))
.ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?;
Some(n as u32)
}
};
let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt);
if count.is_none() && spacing.is_none() {
let (count, spacing) = current_columns(s);
s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) });
return ok();
}
format_body(s, &move |b| {
if let Some(n) = count {
b.columns = Some(n);
}
if let Some(sp) = spacing {
b.col_spacing = Some(sp);
}
})
}
pub(super) fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
let m = match str_param(p, "mode").unwrap_or("none") {
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
"resize" | "shape" => AutoFit::Shape,
_ => AutoFit::None,
};
format_body(s, &move |b| b.autofit = Some(m))
}
pub(super) fn wrap(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_or(p, "on", true);
format_body(s, &move |b| b.wrap = Some(on))
}
pub(super) fn margins(s: &mut Session, p: &Value) -> Result<Value> {
let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom"));
format_body(s, &move |bp| {
if l.is_some() {
bp.inset_l = l.map(|v| v.max(0.0));
}
if t.is_some() {
bp.inset_t = t.map(|v| v.max(0.0));
}
if r.is_some() {
bp.inset_r = r.map(|v| v.max(0.0));
}
if b.is_some() {
bp.inset_b = b.map(|v| v.max(0.0));
}
})
}
+116
View File
@@ -0,0 +1,116 @@
use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params};
use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve};
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
/// and where its text area sits on the slide.
pub(super) fn ruler(s: &mut Session, _p: &Value) -> Result<Value> {
let props = current_para(s);
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?;
let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) {
(false, Some(sh)) => {
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
let x = match &ctx {
Some(c) => resolve::xfrm(c, sh),
None => sh.xfrm.unwrap_or_default(),
};
let body = match &ctx {
Some(c) => resolve::body(c, sh),
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
};
let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6));
(x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal))
}
_ => (0.0, 0.0, 0.0, true),
};
let tabs: Vec<Value> = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
Ok(json!({
"x": x, "y": y, "width": w, "rotated": rotated,
"marginLeft": props.margin_left.unwrap_or(0.0),
"indent": props.indent.unwrap_or(0.0),
"tabs": tabs,
"defaultTab": props.default_tab.unwrap_or(72.0),
}))
}
const MAX_TABS: usize = 32;
pub(super) fn tabs(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "format.tabs";
let fin = |v: Option<f64>| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0));
let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") {
"ctr" | "center" => "ctr",
"r" | "right" => "r",
"dec" | "decimal" => "dec",
_ => "l",
};
let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string())));
let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?)));
let remove = fin(p.get("remove").and_then(Value::as_f64));
// `set` replaces every stop: entries without a usable position are dropped, an unknown
// alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins).
let set = match p.get("set") {
None => None,
Some(Value::Array(items)) => {
if items.len() > 64 {
return Err(bad(CMD, "`set` has more than 64 tab stops"));
}
let mut out: Vec<TabStop> = Vec::new();
for (i, item) in items.iter().enumerate() {
let Some(obj) = item.as_object() else {
return Err(bad(CMD, format!("tab stop {i} must be an object")));
};
let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else {
continue;
};
let align = match obj.get("align").and_then(Value::as_str) {
None => "l",
Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?,
};
if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) {
*prev = TabStop { pos, align: align.to_string() };
} else {
out.push(TabStop { pos, align: align.to_string() });
}
}
Some(out)
}
Some(_) => return Err(bad(CMD, "`set` must be an array")),
};
let clear = bool_or(p, "clear", false);
let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0));
let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear;
if !edits_stops && default_tab.is_none() {
// No fields: open the Tabs dialog on the caret's paragraph.
s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) });
return ok();
}
// The stops as the caret's paragraph shows them (inherited ones included), so editing a
// placeholder's tab that came from its layout works like editing one set on the paragraph.
let base = current_para(s).tabs.unwrap_or_default();
let near = |a: f64, b: f64| (a - b).abs() < 0.5;
let mut stops = if clear { vec![] } else { set.unwrap_or(base) };
if let Some(at) = remove {
stops.retain(|t| !near(t.pos, at));
}
if let Some((from, to)) = mv
&& let Some(t) = stops.iter_mut().find(|t| near(t.pos, from))
{
t.pos = to;
}
if let Some((pos, align)) = add {
stops.retain(|t| !near(t.pos, pos));
stops.push(TabStop { pos, align });
}
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
stops.dedup_by(|a, b| near(a.pos, b.pos));
stops.truncate(MAX_TABS);
format_para(s, &move |para| {
if edits_stops {
para.props.tabs = Some(stops.clone());
}
if let Some(d) = default_tab {
para.props.default_tab = Some(d);
}
})
}
@@ -0,0 +1,139 @@
use super::{
Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param,
};
pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).shadow.is_some()));
let sh = deckcraft_model::style::Shadow {
color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)),
blur: 3.0,
dist: 2.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
};
format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None })
}
pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
let w = f64_or(p, "width", 0.75);
format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w)))
}
pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result<Value> {
let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?;
let clear = preset == "none" || preset == "textNoShape";
if !clear && !deckcraft_geom::warp::is_supported(preset) {
return Err(bad("format.textWarp", format!("unknown text transform `{preset}`")));
}
let adj = match p.get("adj") {
None => Vec::new(),
Some(Value::Array(arr)) => {
if arr.len() > 8 {
return Err(bad("format.textWarp", "at most 8 adjust values"));
}
let mut v = Vec::with_capacity(arr.len());
for a in arr {
let Some(n) = a.as_f64().filter(|n| n.is_finite()) else {
return Err(bad("format.textWarp", "adjust values must be finite numbers"));
};
v.push(n.clamp(-2.0e9, 2.0e9));
}
v
}
Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")),
};
let stored = if clear { None } else { Some(preset.to_string()) };
format_body(s, &|b| {
b.warp.clone_from(&stored);
b.warp_adj.clone_from(&adj);
})
}
pub(super) fn case(s: &mut Session, p: &Value) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (mut a, mut b) = t.ordered();
let body = crate::cmd::text::body_of(st, &t).cloned().unwrap_or_default();
let collapsed = a == b;
if collapsed {
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
let (wa, wb) = ed::word_at(&body, a);
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
return ok();
}
(a, b) = (wa, wb);
}
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
crate::cmd::text::edit_text(s, move |body, t| {
let last = body.paragraphs.len().saturating_sub(1);
for pi in a.0..=b.0.min(last) {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { n };
let mut pos = 0;
let mut pieces = Vec::new();
for (ri, r) in para.runs.iter().enumerate() {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let len = r.char_len();
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
pieces.push((ri, rs, re));
}
pos += len;
}
let mids: Vec<String> = pieces
.iter()
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
.collect();
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
let pre: String = r.text.chars().take(rs).collect();
let post: String = r.text.chars().skip(re).collect();
r.text = format!("{pre}{new}{post}");
}
}
if collapsed {
t.anchor = a;
t.caret = a;
}
})?;
return ok();
}
let mode = if mode == "cycle" {
let all: String =
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
"
",
);
ed::next_case(&all).to_string()
} else {
mode
};
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
let changed = ed::change_case_parts(&texts, &mode);
for (r, new) in para.runs.iter_mut().zip(changed) {
if r.kind == deckcraft_model::text::RunKind::Text {
r.text = new;
}
}
}
}
Ok(())
})
}
+226 -4
View File
@@ -4,10 +4,11 @@
//! slide. Everything is computed here, offline.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
use crate::{Result, Selection, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -57,9 +58,44 @@ fn roles(shapes: &[Shape]) -> Roles {
r
}
/// The ideas for slide `index` (none when it has nothing to arrange).
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
match ctx {
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
}
}
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
/// the ideas do not know, so a slide holding one gets no ideas.
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
match s.kind {
ShapeKind::Picture { .. } => true,
ShapeKind::Shape => {
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
// (filled or outlined) would stay where it is, over the rearranged slide.
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
}
_ => false,
}
}
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
let Some(slide) = doc.slides.get(index) else { return vec![] };
let ctx = Ctx::for_slide(doc, slide);
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
return vec![];
}
// The ideas move one picture; a second would stay put, over the rearranged text.
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
return vec![];
}
let r = roles(&slide.shapes);
if r.picture.is_some() {
PICTURE_IDEAS.to_vec()
@@ -84,6 +120,22 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
}
}
/// 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) {
@@ -180,8 +232,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
let pic = slide.shapes.remove(p);
slide.shapes.insert(0, pic);
}
if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))));
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(())
}
@@ -228,6 +285,7 @@ mod tests {
};
let body = Shape {
id: ShapeId(501),
text_box: true,
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
..Default::default()
@@ -314,6 +372,170 @@ mod tests {
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 slides_with_diagrams_or_other_graphics_get_no_ideas() {
// A filled autoshape with text (a diagram box) is not text to restack.
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(510),
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
// A connector too, and a hidden one does not count.
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(511),
kind: ShapeKind::Connector { start: None, end: None },
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
}
#[test]
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
let mut s = deck(true);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(503),
kind: ShapeKind::Picture { fill: PictureFill::default() },
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
}
#[test]
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
}
#[test]
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(512),
text_box: true,
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
fill: Some(Fill::None),
..Default::default()
});
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
}
#[test]
fn an_empty_box_filled_through_its_style_blocks_ideas() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
style.line_ref.0 = 0;
slide.shapes.push(Shape {
id: ShapeId(512),
text_box: true,
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
style: Some(style),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
}
#[test]
fn cover_crop_matches_the_box_aspect() {
let c = cover_crop(2.0, 1.0);
File diff suppressed because it is too large Load Diff
+724
View File
@@ -0,0 +1,724 @@
use super::picture::{content_type, media_bytes};
use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json};
const ALBUM_MAX_ITEMS: usize = 48;
const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024;
const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024;
const ALBUM_MAX_TEXT: usize = 400;
#[derive(Clone, Copy)]
struct AlbumLayout {
cols: usize,
rows: usize,
per: usize,
title: bool,
fit: bool,
}
#[derive(Clone, Copy)]
enum AlbumFrame {
None,
Rect,
Rounded,
Simple,
}
struct AlbumPic {
name: String,
bytes: Vec<u8>,
px_w: f64,
px_h: f64,
rot: f64,
brightness: f64,
contrast: f64,
}
struct AlbumOpts {
layout_key: String,
frame_key: String,
layout: AlbumLayout,
frame: AlbumFrame,
captions: bool,
grayscale: bool,
theme: Option<String>,
}
enum AlbumItem {
Picture(AlbumPic),
Text(String),
}
enum AlbumSlide {
Pictures(Vec<AlbumPic>),
Text(String),
}
/// Insert a photo album after the current slide.
///
/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one
/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit.
pub(super) fn photo_album(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("pictures").is_none_or(Value::is_null) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
return Ok(json!({"dialog": "photoAlbum"}));
}
let opts = album_opts(p)?;
let items = album_items(p)?;
commit_album(s, items, opts, None)
}
pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("pictures").is_none_or(Value::is_null) {
let seed = album_seed(s)?;
let album = seed.get("album").cloned().unwrap_or(Value::Null);
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed });
return Ok(json!({"dialog": "photoAlbum", "album": album}));
}
let opts = album_opts(p)?;
let items = album_items(p)?;
let (id, ids) = album_target(s, p)?;
commit_album(s, items, opts, Some((id, ids)))
}
fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace: Option<(u32, Vec<u64>)>) -> Result<Value> {
let (snapshot, sel) = {
let st = s.doc()?;
(st.doc.clone(), st.selection.clone())
};
let result = (|| {
let album_id = match replace.as_ref().map(|(id, _)| *id) {
Some(id) if id != 0 => id,
_ => {
let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" };
s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))?
}
};
let at = if let Some((_, ids)) = &replace {
let st = s.doc()?;
let indices: Vec<usize> = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect();
let Some(at) = indices.iter().copied().min() else {
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
};
s.execute("slide.delete", &json!({"ids": ids}))?;
Some(at)
} else {
None
};
let mut opts = opts;
if opts.theme.as_deref().unwrap_or("").is_empty() {
opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty());
}
apply_theme_if_needed(s, opts.theme.as_deref())?;
insert_album(s, items, &opts, at, album_id)
})();
if result.is_err()
&& let Some(st) = s.active_mut()
{
st.doc = snapshot;
st.selection = sel;
}
result
}
fn album_norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
}
fn album_layout(p: &Value) -> Result<AlbumLayout> {
let key = match p.get("layout") {
None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
Some(Value::String(s)) => s.as_str(),
Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")),
};
match album_norm(key).as_str() {
"fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }),
"1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
"1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }),
"2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }),
"2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }),
"4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }),
"4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }),
_ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))),
}
}
fn album_frame(p: &Value) -> Result<AlbumFrame> {
let key = match p.get("frame") {
None => return Ok(AlbumFrame::None),
Some(Value::String(s)) => s.as_str(),
Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")),
};
match album_norm(key).as_str() {
"none" => Ok(AlbumFrame::None),
"rect" | "rectangle" => Ok(AlbumFrame::Rect),
"rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded),
"simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple),
_ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))),
}
}
fn album_captions(p: &Value) -> Result<bool> {
match p.get("captions") {
None => Ok(false),
Some(Value::Bool(v)) => Ok(*v),
Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")),
}
}
fn album_bool(p: &Value, key: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(v)) => Ok(*v),
Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))),
}
}
fn layout_key_of(layout: AlbumLayout) -> String {
if layout.fit {
return "fit".into();
}
let n = match layout.per {
2 => "2",
4 => "4",
_ => "1",
};
if layout.title { format!("{n}title") } else { n.into() }
}
fn frame_key_of(frame: AlbumFrame) -> &'static str {
match frame {
AlbumFrame::None => "none",
AlbumFrame::Rect => "rect",
AlbumFrame::Rounded => "rounded",
AlbumFrame::Simple => "simple",
}
}
fn album_opts(p: &Value) -> Result<AlbumOpts> {
let layout = album_layout(p)?;
let frame = album_frame(p)?;
let theme = match p.get("theme") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.trim().is_empty() => None,
Some(Value::String(s)) => {
let name = s.trim();
let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name));
let Some(t) = found else {
return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`")));
};
Some(t.name)
}
Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")),
};
Ok(AlbumOpts {
layout_key: layout_key_of(layout),
frame_key: frame_key_of(frame).into(),
layout,
frame,
captions: album_captions(p)?,
grayscale: album_bool(p, "grayscale")?,
theme,
})
}
/// A finite number, or 0 when the key is absent. A present non-number is an error.
fn album_finite(item: &Value, key: &str) -> Result<f64> {
match item.get(key) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))),
}
}
fn album_rot(item: &Value) -> Result<f64> {
let n = album_finite(item, "rot")?;
Ok(((n % 360.0) + 360.0) % 360.0)
}
fn album_unit(item: &Value, key: &str) -> Result<f64> {
let n = album_finite(item, key)?;
if !(-1.0..=1.0).contains(&n) {
return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1")));
}
Ok(n)
}
fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
};
if arr.is_empty() {
return Err(bad("insert.photoAlbum", "add at least one picture or text box"));
}
if arr.len() > ALBUM_MAX_ITEMS {
return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes"));
}
let mut items = Vec::with_capacity(arr.len());
let mut total = 0usize;
for item in arr {
let Some(obj) = item.as_object() else {
return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box"));
};
let has_media = obj.contains_key("data") || obj.contains_key("path");
if !has_media {
let Some(text) = obj.get("text").and_then(Value::as_str) else {
return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`"));
};
let text = text.trim();
if text.is_empty() {
return Err(bad("insert.photoAlbum", "a text box needs some text"));
}
if text.chars().count() > ALBUM_MAX_TEXT {
return Err(bad("insert.photoAlbum", "text is longer than 400 characters"));
}
items.push(AlbumItem::Text(text.to_string()));
continue;
}
let (name, bytes) = media_bytes(item, "insert.photoAlbum")?;
if bytes.len() > ALBUM_MAX_BYTES {
return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB")));
}
let next = total.saturating_add(bytes.len());
if next > ALBUM_MAX_TOTAL {
return Err(bad("insert.photoAlbum", "the album is larger than 64 MB"));
}
total = next;
let ct = content_type(&name, &bytes);
if !ct.starts_with("image/") {
return Err(bad("insert.photoAlbum", format!("{name} is not a picture")));
}
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
}
let (px_w, px_h) = image_px(&bytes);
items.push(AlbumItem::Picture(AlbumPic {
name,
bytes,
px_w,
px_h,
rot: album_rot(item)?,
brightness: album_unit(item, "brightness")?,
contrast: album_unit(item, "contrast")?,
}));
}
Ok(items)
}
fn image_px(bytes: &[u8]) -> (f64, f64) {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok())
.map(|(w, h)| (w as f64, h as f64))
.filter(|(w, h)| *w > 0.0 && *h > 0.0)
.unwrap_or((4.0, 3.0))
}
fn file_stem(name: &str) -> String {
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
let stem = match base.rsplit_once('.') {
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
_ => base,
};
if stem.is_empty() { "Picture".to_string() } else { stem.to_string() }
}
fn album_plan(items: Vec<AlbumItem>, per: usize) -> Vec<AlbumSlide> {
let per = per.max(1);
let mut out = Vec::new();
let mut batch = Vec::new();
for item in items {
match item {
AlbumItem::Text(text) => {
if !batch.is_empty() {
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
}
out.push(AlbumSlide::Text(text));
}
AlbumItem::Picture(pic) => {
batch.push(pic);
if batch.len() >= per {
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
}
}
}
}
if !batch.is_empty() {
out.push(AlbumSlide::Pictures(batch));
}
out
}
struct Placed {
pic: [f64; 4],
caption: Option<[f64; 4]>,
}
fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) {
let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 };
let box_w = box_w.max(1.0);
let box_h = box_h.max(1.0);
if !aspect.is_finite() || aspect <= 0.0 {
return (box_w, box_h);
}
if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) }
}
fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
let margin = if layout.fit { 18.0 } else { 36.0 };
let title_band = if layout.title { 48.0 } else { 0.0 };
let gap = 12.0;
let cap_h = 22.0;
let cap_gap = 4.0;
let left = margin;
let top = margin + title_band;
let grid_w = (width - margin - left).max(1.0);
let grid_h = (height - margin - top).max(1.0);
let cols = layout.cols.max(1);
let rows = layout.rows.max(1);
let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0);
let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0);
let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h };
let mut placed = Vec::with_capacity(n);
for i in 0..n {
let col = i % cols;
let row = i / cols;
if row >= rows {
break;
}
let cell_x = left + col as f64 * (cell_w + gap);
let cell_y = top + row as f64 * (cell_h + gap);
placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None });
}
let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None };
(title, placed)
}
fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
let (title, slots) = place_album(width, height, layout, pics.len(), captions);
let placed = pics
.iter()
.zip(slots)
.map(|(pic, slot)| {
let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h);
let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0;
let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0;
let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None };
Placed { pic: [x, y, dw, dh], caption }
})
.collect();
(title, placed)
}
fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> {
let params = match frame {
AlbumFrame::None => return Ok(()),
AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}),
AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}),
AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}),
};
s.execute("shape.line", &params)?;
Ok(())
}
struct AlbumMark {
id: u32,
layout: String,
frame: String,
captions: bool,
grayscale: bool,
theme: String,
}
fn parse_album_mark(raw: &str) -> Option<AlbumMark> {
let rest = raw.strip_prefix("deckcraft-album:")?;
let (id_str, rest) = rest.split_once(';').unwrap_or((rest, ""));
let id = id_str.parse::<u32>().ok().filter(|id| *id != 0)?;
let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() };
if !rest.is_empty() {
for part in rest.split(';') {
let Some((k, v)) = part.split_once('=') else { continue };
match k {
"layout" => mark.layout = v.to_string(),
"frame" => mark.frame = v.to_string(),
"captions" => mark.captions = v == "1",
"gray" => mark.grayscale = v == "1",
"theme" => mark.theme = v.to_string(),
_ => {}
}
}
}
Some(mark)
}
fn album_marker(id: u32, opts: &AlbumOpts) -> String {
let theme = opts.theme.as_deref().unwrap_or("");
format!(
"deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}",
opts.layout_key,
opts.frame_key,
if opts.captions { 1 } else { 0 },
if opts.grayscale { 1 } else { 0 },
)
}
fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> {
let marker = album_marker(album_id, opts);
s.edit(|doc, sel| {
let Some(slide) = doc.slides.get_mut(sel.slide) else {
return Err(bad("insert.photoAlbum", "no slide to mark"));
};
let slide = std::sync::Arc::make_mut(slide);
if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) {
slide.raw_ext = Some(marker);
}
slide.name = "Photo Album".into();
Ok(())
})
}
fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> {
let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) };
let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default();
if current.eq_ignore_ascii_case(name) {
return Ok(());
}
s.execute("design.theme", &json!({"name": name}))?;
Ok(())
}
fn album_id_param(p: &Value) -> Option<u32> {
match p.get("album") {
Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0),
Some(Value::String(s)) => s.trim().parse::<u32>().ok().filter(|v| *v != 0),
_ => None,
}
}
fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec<u64> {
doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect()
}
fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec<u64> {
if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") {
return Vec::new();
}
let mut start = index;
while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") {
start -= 1;
}
let mut end = index;
while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") {
end += 1;
}
doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect()
}
/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker).
fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
let st = s.doc()?;
if let Some(id) = album_id_param(p) {
let ids = slides_with_album(&st.doc, id);
if ids.is_empty() {
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
}
return Ok((id, ids));
}
let Some(slide) = st.doc.slides.get(st.selection.slide) else {
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
};
if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) {
let ids = slides_with_album(&st.doc, mark.id);
if ids.is_empty() {
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
}
return Ok((mark.id, ids));
}
if slide.name == "Photo Album" {
let ids = album_name_run(&st.doc, st.selection.slide);
if !ids.is_empty() {
return Ok((0, ids));
}
}
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
}
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
}
fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) {
let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1);
let per = if max_pics >= 3 {
"4"
} else if max_pics == 2 {
"2"
} else {
"1"
};
let title =
slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0)));
let layout = if title { format!("{per}title") } else { per.into() };
let width = slides.iter().find_map(|s| {
s.shapes.iter().find_map(|sh| match &sh.kind {
ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width),
_ => None,
})
});
let frame = match width {
None => "none",
Some(w) if (w - 6.0).abs() < 0.2 => "simple",
Some(w) if (w - 2.25).abs() < 0.2 => "rounded",
Some(w) if w < 0.2 => "none",
Some(_) => "rect",
};
let captions = slides.iter().any(|s| {
let bottoms: Vec<f64> =
s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect();
!bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b)))
});
let mut any = false;
let mut gray = true;
for s in slides {
for sh in &s.shapes {
if let ShapeKind::Picture { fill } = &sh.kind {
any = true;
gray &= fill.adjust.grayscale;
}
}
}
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
let mut items = Vec::new();
for slide in slides {
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
if pics.is_empty() {
let text = slide
.shapes
.iter()
.filter(|sh| sh.text_box)
.filter_map(|sh| sh.text.as_ref().map(|t| t.text()))
.find(|t| !t.trim().is_empty())
.unwrap_or_default();
if !text.trim().is_empty() {
items.push(json!({"text": text.trim()}));
}
continue;
}
for sh in pics {
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let media = doc.media(fill.media);
let name = if !sh.descr.is_empty() {
sh.descr.clone()
} else {
media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into())
};
items.push(json!({
"name": name,
"data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(),
"rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0),
"brightness": fill.adjust.brightness,
"contrast": fill.adjust.contrast,
}));
}
}
items
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides: Vec<&deckcraft_model::Slide> =
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
let inferred = infer_album(&slides, &theme_now);
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark {
(
if mark.layout.is_empty() { inferred.0 } else { mark.layout },
if mark.frame.is_empty() { inferred.1 } else { mark.frame },
mark.captions,
mark.grayscale,
if mark.theme.is_empty() { theme_now } else { mark.theme },
)
} else {
inferred
};
Ok(json!({
"album": if id == 0 { Value::Null } else { json!(id) },
"layout": layout,
"frame": frame,
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items_from_slides(&st.doc, &slides),
}))
}
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Option<usize>, album_id: u32) -> Result<Value> {
let (width, height, first) = {
let st = s.doc()?;
let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) });
(st.doc.slide_size.width, st.doc.slide_size.height, first)
};
let layout = opts.layout;
let frame = opts.frame;
let captions = opts.captions;
let plan = album_plan(items, layout.per);
let mut slides = 0usize;
let mut pictures = 0usize;
let mut pin = at;
for slide in plan {
if let Some(index) = pin.take() {
s.execute("slide.new", &json!({"layout": "blank", "at": index}))?;
} else {
s.execute("slide.new", &json!({"layout": "blank"}))?;
}
stamp_album_slide(s, album_id, opts)?;
slides += 1;
match slide {
AlbumSlide::Text(text) => {
let rect = if layout.title {
[36.0, 18.0, (width - 72.0).max(36.0), 48.0]
} else {
[36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)]
};
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?;
}
AlbumSlide::Pictures(pics) => {
let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default();
let (title, placed) = place_pictures(width, height, layout, &pics, captions);
for (pic, cell) in pics.iter().zip(placed) {
let data = base64_encode(&pic.bytes);
let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?;
let Some(id) = inserted.get("id").and_then(Value::as_u64) else {
return Err(bad("insert.photoAlbum", "the picture was not inserted"));
};
apply_frame(s, frame, id)?;
if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 {
s.execute(
"picture.adjust",
&json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}),
)?;
}
if pic.rot > 0.01 {
s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?;
}
pictures += 1;
if let Some(cap) = cell.caption {
s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?;
}
}
if let Some(rect) = title {
s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?;
}
}
}
}
s.execute("slide.go", &json!({"index": first}))?;
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
}
+221
View File
@@ -0,0 +1,221 @@
use super::{
Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json,
rect_param, str_param,
};
pub fn chart_type(name: &str) -> ChartType {
match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
"bar" | "clusteredbar" => ChartType::Bar,
"stackedbar" => ChartType::StackedBar,
"percentbar" => ChartType::PercentBar,
"line" => ChartType::Line,
"linemarkers" | "linewithmarkers" => ChartType::LineMarkers,
"stackedline" => ChartType::StackedLine,
"area" => ChartType::Area,
"stackedarea" => ChartType::StackedArea,
"pie" => ChartType::Pie,
"doughnut" | "donut" => ChartType::Doughnut,
"scatter" | "xy" => ChartType::Scatter,
"bubble" => ChartType::Bubble,
"radar" => ChartType::Radar,
"filledradar" => ChartType::FilledRadar,
"stackedcolumn" => ChartType::StackedColumn,
"percentcolumn" => ChartType::PercentColumn,
"waterfall" => ChartType::Waterfall,
"funnel" => ChartType::Funnel,
"histogram" => ChartType::Histogram,
"pareto" => ChartType::Pareto,
"treemap" => ChartType::Treemap,
"sunburst" => ChartType::Sunburst,
"boxwhisker" | "box" => ChartType::BoxWhisker,
"stock" => ChartType::Stock,
"surface" => ChartType::Surface,
"combo" => ChartType::Combo,
_ => ChartType::Column,
}
}
pub(super) fn chart(s: &mut Session, p: &Value) -> Result<Value> {
let kind = chart_type(str_param(p, "type").unwrap_or("column"));
let mut c = Chart::sample(kind);
if let Some(cats) = p.get("categories").and_then(Value::as_array) {
c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect();
}
if let Some(series) = p.get("series").and_then(Value::as_array) {
c.series = series
.iter()
.map(|sv| deckcraft_model::chart::Series {
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(),
..Default::default()
})
.collect();
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(String::from);
}
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7));
let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
Ok(json!({"id": id}))
}
#[allow(dead_code)]
fn _kinds() -> RunKind {
RunKind::Text
}
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<Value> {
edit_shapes(s, p, cmd, |sh| match &mut sh.kind {
ShapeKind::Chart(c) => f(c),
_ => Err(bad(cmd, "not a chart")),
})
}
pub(super) fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let k = chart_type(str_param(p, "type").unwrap_or("column"));
with_chart(s, p, "chart.type", |c| {
c.kind = k;
c.vary_colors =
matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall)
|| c.series.len() == 1 && c.vary_colors;
if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
c.gridlines = true;
}
c.raw = None;
Ok(())
})
}
pub(super) fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
let cats: Option<Vec<String>> = p
.get("categories")
.and_then(Value::as_array)
.map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect());
let series: Option<Vec<deckcraft_model::chart::Series>> = p.get("series").and_then(Value::as_array).map(|a| {
a.iter()
.map(|sv| deckcraft_model::chart::Series {
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(),
..Default::default()
})
.collect()
});
let levels: Option<Vec<Vec<String>>> = p.get("levels").and_then(Value::as_array).map(|a| {
a.iter()
.take(deckcraft_model::chart::MAX_LEVELS)
.map(|l| {
l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default()
})
.collect()
});
with_chart(s, p, "chart.data", |c| {
if let Some(cs) = &cats {
c.categories = cs.clone();
}
if let Some(l) = &levels {
c.category_levels = l.clone();
}
if let Some(se) = &series {
// Keep series formatting by position.
let old = std::mem::take(&mut c.series);
c.series = se
.iter()
.enumerate()
.map(|(i, n)| {
let mut x = old.get(i).cloned().unwrap_or_default();
x.name = n.name.clone();
x.values = n.values.clone();
x
})
.collect();
}
c.raw = None;
Ok(())
})
}
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
}
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
if let Some(t) = subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
c.up_down_bars = b;
c.raw = None;
}
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
c.raw = None;
}
}
pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0));
if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() {
return Err(bad("chart.options", "binSize must be a positive number"));
}
// Waterfall total points.
let subtotals: Option<Vec<usize>> =
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
// default (tx1 at lumMod 65 % / lumOff 35 %).
let text_color = match p.get("textColor") {
None => None,
Some(Value::Null) => Some(None),
Some(v) => {
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
}
};
with_chart(s, p, "chart.options", |c| {
if let Some(t) = &text_color {
c.text_color = t.clone();
}
apply_bin_options(c, bin_count, bin_size);
apply_subtotals_and_bars(c, p, subtotals.as_deref());
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
c.raw = None;
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(String::from);
}
if let Some(l) = p.get("legend") {
c.legend = l.as_str().map(String::from);
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
// A series' own setting would otherwise keep overriding the chart-wide switch.
for series in &mut c.series {
series.data_labels = None;
}
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
}
if let Some(v) = str_param(p, "palette") {
c.palette = v.to_string();
}
c.raw = None;
Ok(())
})
}
@@ -0,0 +1,284 @@
use deckcraft_model::ShapeKind;
use deckcraft_model::style::LineJoin;
use super::*;
/// 8×8 red PNG.
fn png() -> Vec<u8> {
vec![
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68,
65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
130,
]
}
fn pic(name: &str) -> Value {
json!({"name": name, "data": base64_encode(&png())})
}
fn n_slides(s: &Session) -> usize {
s.active().map(|d| d.doc.slides.len()).unwrap_or(0)
}
fn pictures(s: &Session, index: usize) -> Vec<deckcraft_model::Shape> {
s.active()
.and_then(|d| d.doc.slides.get(index))
.map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect())
.unwrap_or_default()
}
fn box_text(s: &Session, index: usize) -> Vec<String> {
s.active()
.and_then(|d| d.doc.slides.get(index))
.map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect())
.unwrap_or_default()
}
#[test]
fn two_pictures_one_per_slide_are_one_undo() {
let mut s = Session::with_new();
let before = n_slides(&s);
let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album");
assert_eq!(r["slides"], 2);
assert_eq!(n_slides(&s), before + 2);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
assert_eq!(s.active().unwrap().selection.slide, 1);
let undo = &s.active().unwrap().history.undo;
assert_eq!(undo.len(), 1);
assert_eq!(undo[0].label, "Photo Album…");
s.execute("edit.undo", &json!({})).expect("undo");
assert_eq!(n_slides(&s), before);
assert!(pictures(&s, 1).is_empty());
}
#[test]
fn two_up_shares_one_slide() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album");
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
assert!(box_text(&s, 1).is_empty());
}
#[test]
fn captions_and_titles_use_the_file_stem() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions");
assert_eq!(box_text(&s, 1), ["harbor"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title");
assert_eq!(box_text(&s, 1), ["harbor"]);
assert_eq!(pictures(&s, 1).len(), 1);
let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y;
assert!(pic_y > 40.0, "the picture sits below the title");
}
#[test]
fn frames_set_the_picture_line() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect");
let line = pictures(&s, 1)[0].line.clone().expect("line");
assert_eq!(line.width, Some(1.5));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded");
let line = pictures(&s, 1)[0].line.clone().expect("line");
assert_eq!(line.width, Some(2.25));
assert_eq!(line.join, Some(LineJoin::Round));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple");
assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0));
}
#[test]
fn a_text_box_breaks_the_picture_row() {
let mut s = Session::with_new();
let r = s
.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"}))
.expect("album");
assert_eq!(r["slides"], 3);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(box_text(&s, 2), ["A day on the water"]);
assert!(pictures(&s, 2).is_empty());
assert_eq!(pictures(&s, 3).len(), 1);
assert_eq!(s.active().unwrap().history.undo.len(), 1);
}
#[test]
fn no_pictures_opens_the_dialog_without_editing() {
let mut s = Session::with_new();
let before = n_slides(&s);
let r = s.execute("insert.photoAlbum", &json!({})).expect("open");
assert_eq!(r["dialog"], "photoAlbum");
assert_eq!(n_slides(&s), before);
assert!(s.active().unwrap().history.undo.is_empty());
assert_eq!(s.ui_requests.len(), 1);
match &s.ui_requests[0] {
crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"),
other => panic!("dialog request, got {other:?}"),
}
}
#[test]
fn bad_input_adds_no_slides() {
let mut s = Session::with_new();
let before = n_slides(&s);
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err());
let mut many = Vec::new();
for i in 0..49 {
many.push(json!({"text": format!("box {i}")}));
}
assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err());
assert_eq!(n_slides(&s), before);
assert!(s.active().unwrap().history.undo.is_empty());
}
#[test]
fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap();
let one = pictures(&s, 1)[0].xfrm.unwrap().h;
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap();
let fitted = pictures(&s, 1)[0].xfrm.unwrap().h;
assert!(fitted > one, "fit {fitted} should be taller than one-up {one}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap();
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 4);
}
fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust {
match &pictures(s, index)[0].kind {
ShapeKind::Picture { fill } => fill.adjust.clone(),
_ => panic!("picture"),
}
}
fn theme_name(s: &Session) -> String {
s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default()
}
#[test]
fn edit_without_pictures_reopens_the_dialog() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
s.ui_requests.clear();
let before = n_slides(&s);
let undos = s.active().unwrap().history.undo.len();
let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen");
assert_eq!(r["dialog"], "photoAlbum");
assert_eq!(n_slides(&s), before);
assert_eq!(s.active().unwrap().history.undo.len(), undos);
let params = match &s.ui_requests[0] {
crate::UiRequest::Dialog { id, params } => {
assert_eq!(id, "photoAlbum");
params
}
other => panic!("dialog request, got {other:?}"),
};
assert_eq!(params["items"][0]["name"], "hill.png");
assert_eq!(params["items"][1]["name"], "lake.png");
assert_eq!(params["layout"], "1");
assert!(params["album"].as_u64().unwrap_or(0) > 0);
}
#[test]
fn edit_reflows_the_album_in_one_undo() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 3);
let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow");
assert_eq!(r["slides"], 1);
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let undo = &s.active().unwrap().history.undo;
assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(n_slides(&s), 3);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
}
#[test]
fn picture_options_and_theme_are_one_step() {
let mut s = Session::with_new();
s.execute(
"insert.photoAlbum",
&json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}),
)
.unwrap();
let adj = adjust(&s, 1);
assert!(adj.grayscale);
assert!((adj.brightness - 0.2).abs() < 1e-6);
assert!((adj.contrast + 0.1).abs() < 1e-6);
assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01);
assert_eq!(theme_name(&s), "Harbor");
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.contains("gray=1"), "{mark}");
assert!(mark.contains("theme=Harbor"), "{mark}");
assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(theme_name(&s), "DeckCraft");
assert_eq!(n_slides(&s), 1);
}
#[test]
fn unknown_theme_and_a_plain_slide_change_nothing() {
let mut s = Session::with_new();
let before = n_slides(&s);
let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err();
assert!(err.to_string().contains("unknown theme"), "{err}");
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err());
let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err();
assert!(err.to_string().contains("not part of a photo album"), "{err}");
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err());
assert_eq!(n_slides(&s), before + 1);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(theme_name(&s), "DeckCraft");
}
#[test]
fn two_albums_stay_independent() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
let last = n_slides(&s) - 1;
s.execute("slide.go", &json!({"index": last})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 5);
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 4);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
assert_eq!(pictures(&s, 3).len(), 2);
let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default();
assert!(first.starts_with("deckcraft-album:"));
assert!(second.starts_with("deckcraft-album:"));
assert_ne!(first.split(';').next(), second.split(';').next());
}
#[test]
fn a_named_run_without_a_marker_still_reflows() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
{
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
for sl in doc.slides.iter_mut().skip(1) {
std::sync::Arc::make_mut(sl).raw_ext = None;
}
}
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.starts_with("deckcraft-album:"), "{mark}");
}
+119
View File
@@ -0,0 +1,119 @@
use super::{
Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param,
str_param, usize_param,
};
pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
let (action, display) = if let Some(u) = str_param(p, "url") {
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
} else if let Some(i) = usize_param(p, "slide") {
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
let title = slide.title();
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
} else {
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
};
let display = str_param(p, "text").map_or(display, str::to_string);
let link = Hyperlink {
action,
tooltip: str_param(p, "tooltip").unwrap_or("").to_string(),
highlight_click: bool_or(p, "highlightClick", false),
sound: None,
stop_sound: false,
zoom_return: None,
};
if s.doc()?.selection.text.is_some() {
return crate::cmd::text::link_text(s, link, &display);
}
edit_shapes(s, p, "insert.hyperlink", |sh| {
sh.click = Some(link.clone());
Ok(())
})
}
/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does
/// in a show.
pub(super) fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
const CMD: &str = "shape.action";
let hover = match str_param(p, "on").unwrap_or("click") {
"click" => false,
"hover" | "over" => true,
o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))),
};
let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`")));
let action = match str_param(p, "action").unwrap_or("none") {
"none" => None,
"next" => Some(Action::NextSlide),
"previous" => Some(Action::PreviousSlide),
"first" => Some(Action::FirstSlide),
"last" => Some(Action::LastSlide),
"lastViewed" => Some(Action::LastViewed),
"endShow" => Some(Action::EndShow),
"playMedia" => Some(Action::PlayMedia),
"url" => Some(Action::Url { url: need("url")? }),
"customShow" => {
let name = need("show")?;
if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) {
return Err(bad(CMD, format!("no custom show `{name}`")));
}
Some(Action::CustomShow { name, returns: true })
}
"program" => Some(Action::Program { path: need("path")? }),
"slide" => {
let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?;
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?;
Some(Action::Slide { slide: slide.id })
}
a => return Err(bad(CMD, format!("unknown action `{a}`"))),
};
let highlight = bool_param(p, "highlight").unwrap_or(true);
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None });
edit_shapes(s, p, CMD, |sh| {
if hover {
sh.hover = link.clone();
} else {
sh.click = link.clone();
}
Ok(())
})
}
pub(super) fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
let kind = str_param(p, "kind").unwrap_or("custom");
let (preset, action) = match kind {
"back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)),
"forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)),
"beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)),
"end" => ("actionButtonEnd", Some(Action::LastSlide)),
"home" => ("actionButtonHome", Some(Action::FirstSlide)),
"information" => ("actionButtonInformation", None),
"return" => ("actionButtonReturn", Some(Action::LastViewed)),
"movie" => ("actionButtonMovie", None),
"document" => ("actionButtonDocument", None),
"sound" => ("actionButtonSound", None),
"help" => ("actionButtonHelp", None),
_ => ("actionButtonBlank", None),
};
let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0));
let shape = Shape {
xfrm: Some(rect),
geom: Geom::preset(preset),
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
click: action.map(|a| Hyperlink {
action: a,
tooltip: String::new(),
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
};
let id = add_shape(s, "insert.actionButton", shape, true)?;
Ok(json!({"id": id}))
}
+246
View File
@@ -0,0 +1,246 @@
use super::{
EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect,
json, rect_param, str_param, xfrm_of,
};
/// Read media bytes from `data` (base64) or `path`.
pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
if let Some(d) = str_param(p, "data") {
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
}
if let Some(path) = str_param(p, "path") {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
return Ok((name, bytes));
}
Err(bad(cmd, "missing `path` or `data`"))
}
pub fn content_type(name: &str, bytes: &[u8]) -> &'static str {
let l = name.to_ascii_lowercase();
if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
"image/png"
} else if bytes.starts_with(&[0xFF, 0xD8]) {
"image/jpeg"
} else if bytes.starts_with(b"GIF8") {
"image/gif"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"image/webp"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") {
"audio/wav"
} else if bytes.starts_with(b"BM") {
"image/bmp"
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
"image/tiff"
} else if bytes.starts_with(b"fLaC") {
"audio/flac"
} else if bytes.starts_with(b"OggS") {
if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" }
} else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 {
// ADTS (layer bits 00): AAC.
"audio/aac"
} else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) {
"audio/mpeg"
} else if bytes.starts_with(b"FORM") {
"audio/aiff"
} else if bytes.get(4..8) == Some(b"ftyp") {
if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") {
"audio/mp4"
} else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") {
"video/quicktime"
} else {
"video/mp4"
}
} else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
if l.ends_with(".mka") || l.ends_with(".weba") {
"audio/webm"
} else if l.ends_with(".mkv") {
"video/x-matroska"
} else {
"video/webm"
}
} else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) {
if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" }
} else if bytes.starts_with(b"caff") {
"audio/x-caf"
} else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b"<svg") {
"image/svg+xml"
} else if l.ends_with(".aac") {
"audio/aac"
} else {
"application/octet-stream"
}
}
/// Natural size of an image in points (96 dpi), fitted inside the slide.
fn fitted(s: &Session, bytes: &[u8]) -> (f64, f64) {
let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok())
.map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75))
.unwrap_or((288.0, 216.0));
let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0);
(w * k, h * k)
}
pub(super) fn picture(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = media_bytes(p, "insert.picture")?;
let ct = content_type(&name, &bytes);
if !ct.starts_with("image/") {
return Err(bad("insert.picture", format!("{name} is not a picture")));
}
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
return Err(EngineError::Other(format!("{name}: the picture can't be read")));
}
let (w, h) = fitted(s, &bytes);
// Into a selected empty picture/content placeholder, if there is one.
let target_ph = s.active().and_then(|d| {
d.selected_shapes()
.into_iter()
.find(|x| {
x.ph_type()
.is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body))
&& x.text.as_ref().is_none_or(|t| t.is_empty())
})
.map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x)))
});
let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph {
Some((_, b)) => {
// Fill the placeholder, cropping the picture to its aspect.
b
}
None => default_rect(s, w, h),
});
let crop = match (target_ph, rect_param(p, "rect")) {
(Some(_), None) => {
let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9));
if ia > ba {
let c = (1.0 - ba / ia) / 2.0;
[c, 0.0, c, 0.0]
} else {
let c = (1.0 - ia / ba) / 2.0;
[0.0, c, 0.0, c]
}
}
_ => [0.0; 4],
};
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?;
let shape = Shape {
xfrm: Some(rect),
kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } },
descr: name.clone(),
..Default::default()
};
if let Some((ph_id, _)) = target_ph {
// Replace the placeholder with the picture (it keeps the placeholder link).
let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone());
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?;
if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) {
sh.kind = shape.kind.clone();
sh.xfrm = Some(rect);
sh.ph = phv.map(|mut p| {
p.kind = deckcraft_model::PhType::Picture;
p
});
sh.text = None;
sh.descr = name.clone();
}
sel.shapes = vec![ph_id];
Ok(())
})?;
return Ok(json!({"id": ph_id}));
}
let id = add_shape(s, "insert.picture", shape, true)?;
Ok(json!({"id": id}))
}
fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
let cmd = if video { "insert.video" } else { "insert.audio" };
let (name, bytes) = media_bytes(p, cmd)?;
let ct = content_type(&name, &bytes);
let bytes = std::sync::Arc::new(bytes);
let info = deckcraft_media::probe(&bytes);
let has_video = info.as_ref().is_ok_and(|i| i.video.is_some());
let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some());
match &info {
Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))),
Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))),
// Not something we can read: embed it anyway when the type says it's media (it still
// round-trips through PPTX), otherwise refuse.
Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))),
Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))),
_ => {}
}
let (duration_ms, width, height, playable) = match &info {
Ok(i) => {
let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0));
(i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable())
}
Err(_) => (0, 0, 0, false),
};
let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable));
let warning = if !playable {
Some(match &info {
Ok(i) => {
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
}
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
})
} else if video && !decodable_video {
// A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame
// can ever show — say so instead of leaving an unexplained black box (#1237).
info.as_ref()
.ok()
.and_then(|i| i.video.as_ref())
.map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec))
} else {
None
};
let poster_png = if video && decodable_video { crate::cmd::media::poster_png(&bytes, 0) } else { None };
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or_else(|| {
if video {
// Natural size (96 dpi), fitted inside 90 % of the slide and centred.
let (w, h) =
if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) };
let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0);
let (w, h) = (w * k, h * k);
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
} else {
Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0)
}
});
let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone());
let (media, poster) = s.edit(|doc, _| {
let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?;
let poster = match poster_png {
Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?),
None => None,
};
Ok((media, poster))
})?;
let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() };
let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?;
if let Some(w) = &warning {
s.ui_requests.push(crate::UiRequest::Message { text: w.clone() });
}
let mut r =
json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning});
if let Ok(i) = &info {
r["probe"] = crate::cmd::media::probe_json(i);
}
Ok(r)
}
pub(super) fn audio(s: &mut Session, p: &Value) -> Result<Value> {
media(s, p, false)
}
pub(super) fn video(s: &mut Session, p: &Value) -> Result<Value> {
media(s, p, true)
}
+246
View File
@@ -0,0 +1,246 @@
use super::{
ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok,
str_param, usize_param,
};
pub(super) fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("Your text here");
let size = s.doc()?.doc.slide_size;
let style = usize_param(p, "style").unwrap_or(0);
let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text);
if let Some(t) = shape.text.as_mut() {
for para in &mut t.paragraphs {
para.props.align = Some(deckcraft_model::text::Align::Center);
for r in &mut para.runs {
r.props.size = Some(54.0);
r.props.bold = Some(style % 2 == 1);
r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 {
0 => SchemeSlot::Tx1,
1 => SchemeSlot::Accent1,
2 => SchemeSlot::Accent2,
_ => SchemeSlot::Accent4,
})));
if style % 3 == 2 {
r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0));
}
}
}
}
let id = add_shape(s, "insert.wordArt", shape, true)?;
crate::cmd::text::fit_text_box(s, id)?;
Ok(json!({"id": id}))
}
fn field_run(s: &mut Session, field: &str, text: &str) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return crate::cmd::text::insert_field(s, field, text);
}
// Not editing: turn on the footer field for the slide (Header & Footer).
let key = if field == "slidenum" { "slideNumber" } else { "date" };
s.execute("design.headerFooter", &json!({key: true}))
}
pub(super) fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
field_run(s, "slidenum", "‹#›")
}
const DATE_FORMATS: [&str; 13] = [
"datetime1",
"datetime2",
"datetime3",
"datetime4",
"datetime5",
"datetime6",
"datetime7",
"datetime8",
"datetime9",
"datetime10",
"datetime11",
"datetime12",
"datetime13",
];
struct DateOpts {
format: String,
update: bool,
/// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language.
lang: Option<String>,
sample: String,
}
/// `true` when the call carries a format, update flag or language (apply it). Empty params open
/// the dialog instead.
fn date_time_apply(p: &Value) -> bool {
p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language"))
}
fn canon_lang(raw: &str) -> Option<&'static str> {
match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"en" | "en-us" => Some("en-US"),
"en-gb" => Some("en-GB"),
"fr" | "fr-fr" => Some("fr-FR"),
"ja" | "ja-jp" => Some("ja-JP"),
"sv" | "sv-se" => Some("sv-SE"),
_ => None,
}
}
fn date_opts(p: &Value) -> Result<DateOpts> {
if p.get("format").is_some() {
let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f));
if f.is_none() {
return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13"));
}
}
if p.get("update").is_some() && bool_param(p, "update").is_none() {
return Err(bad("insert.dateTime", "`update` must be true or false"));
}
let lang = if p.get("language").is_some() {
let raw = str_param(p, "language").filter(|l| !l.trim().is_empty());
let Some(raw) = raw else {
return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"));
};
Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string())
} else {
None
};
let format = str_param(p, "format").unwrap_or("datetime1").to_string();
let sample = date_sample(&format, lang.as_deref())?;
Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample })
}
/// The text `format` shows now in `lang`. Errors when that language has no names for the format
/// (month names and AM/PM are English only). An empty string where there is no clock (wasm).
fn date_sample(format: &str, lang: Option<&str>) -> Result<String> {
use deckcraft_text::datetime::{DateTime, field_text};
let probe = DateTime::new(2026, 10, 9, 20, 5, 7);
if field_text(format, lang, probe).is_none() {
return Err(bad("insert.dateTime", "this format isn't available in that language"));
}
Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default())
}
pub(super) fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
if !date_time_apply(p) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) });
return ok();
}
let opts = date_opts(p)?;
let (doc, sel) = {
let st = s.doc()?;
(st.doc.clone(), st.selection.clone())
};
let r = apply_date(s, &opts);
if r.is_err()
&& let Some(st) = s.active_mut()
{
st.doc = doc;
st.selection = sel;
}
r
}
fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
if o.update {
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
if let Some(lang) = &o.lang {
stamp_caret_lang(s, lang)?;
}
} else {
insert_plain(s, &o.sample, o.lang.as_deref())?;
}
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
}
// Not editing: the slide's date placeholder (this slide only). A layout with no date
// placeholder gets a text box instead, so the chosen format is still inserted.
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
if !write_date_placeholder(s, o)? {
return insert_date_box(s, o);
}
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
}
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
let format = o.format.clone();
let sample = o.sample.clone();
let lang = o.lang.clone();
let update = o.update;
s.edit(|doc, sel| {
let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) };
let sl = std::sync::Arc::make_mut(sl);
let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else {
return Ok(false);
};
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
if let Some(lang) = lang {
props.lang = Some(lang);
}
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() };
let body = sh.text.clone().unwrap_or_default();
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
Ok(true)
})
}
fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result<Value> {
let size = s.doc()?.doc.slide_size;
let y = (size.height - 48.0).max(0.0);
s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?;
if o.update {
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
} else {
crate::cmd::text::insert_text(s, &o.sample, None)?;
}
if let Some(lang) = &o.lang {
stamp_caret_lang(s, lang)?;
}
Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample}))
}
/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge,
/// so the date stays its own run next to the surrounding text.
fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> {
let text = text.to_string();
let lang = lang.map(str::to_string);
crate::cmd::text::edit_text(s, move |body, t| {
let (a, b) = t.ordered();
let at = deckcraft_model::edit::delete(body, a, b);
let mut props = deckcraft_model::edit::props_at(body, at);
if let Some(lang) = lang.clone() {
props.lang = Some(lang);
}
let c = deckcraft_model::edit::insert(body, at, &text, Some(props));
t.anchor = c;
t.caret = c;
})?;
Ok(())
}
/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates.
fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> {
let lang = lang.to_string();
crate::cmd::text::edit_text(s, move |body, t| {
let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return };
let mut at = 0usize;
for r in &mut p.runs {
let n = r.char_len();
if n > 0 && at < t.caret.1 && t.caret.1 <= at + n {
r.props.lang = Some(lang);
return;
}
at += n;
}
})?;
Ok(())
}
pub(super) fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string();
if s.doc()?.selection.text.is_none() {
return Err(bad("insert.symbol", "place the insertion point in text first"));
}
crate::cmd::text::insert_text(s, &t, None)
}
+1 -1
View File
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+33 -2
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::ShapeId;
use serde_json::json;
use crate::cmd::base64_encode;
use crate::{MediaStatus, Session, UiRequest};
fn wav() -> Vec<u8> {
let rate = 8000u32;
@@ -228,6 +232,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();
@@ -271,4 +290,16 @@ mod tests {
assert_eq!(r["playable"], false);
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
}
#[test]
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
let mut s = Session::with_new();
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
}
}
+7 -2
View File
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::single::SingleFloatOverlay;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
use crate::{Result, Session};
/// Closed polygons (slide points) describing a filled area (non-zero winding).
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::{Area, area_of, area_size, letters_of, merge_areas};
use crate::Session;
use crate::cmd::base64_encode;
fn square(x: f64, y: f64, s: f64) -> Area {
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
use deckcraft_model::{ColorRef, Geom, Line, Shape};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+301 -52
View File
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
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!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
cmd!(
"shape.addPoint",
@@ -54,6 +54,42 @@ pub fn specs() -> Vec<CommandSpec> {
move_handle
),
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
cmd!(
"shape.deleteSegment",
"Delete Segment",
[],
None,
"{path?: 0, segment: index of the vertex ending it, id?}: removes a segment of a closed outline, which opens there; it starts at the segment's end and ends at its start",
has_selection,
delete_segment
),
cmd!(
"shape.segmentType",
"Segment Type",
[],
None,
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
has_selection,
segment_type
),
cmd!(
"shape.openPath",
"Open Path",
[],
None,
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
has_selection,
open_path
),
cmd!(
"shape.closePath",
"Close Path",
[],
None,
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
has_selection,
close_path
),
]
}
@@ -281,6 +317,92 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
true
}
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
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());
(from, to)
}
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
/// sub-path's start, none for an open one's.
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
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)
}
}
/// Whether segment `j` is a straight line or a curve.
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
match segs.get(j)? {
Seg::L(_) => Some("straight"),
Seg::C(..) | Seg::Q(..) => Some("curved"),
Seg::M(_) | Seg::Z => None,
}
}
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
/// thirds, so it keeps its shape until a handle is dragged.
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
if curved {
to_cubic(segs, j);
} else if let Some(s) = segs.get_mut(j)
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
{
*s = Seg::L(p);
}
}
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
/// on that vertex, as two points. Returns false for an open sub-path.
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, to) = subpath(segs, si);
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
let mut out = vec![Seg::M(v)];
out.extend_from_slice(after);
out.extend_from_slice(before);
segs.splice(from..to, out);
true
}
/// Deletes the segment ending at the vertex ending segment `si` from its closed sub-path, which
/// opens there: it now starts at the segment's end and ends at its start. Returns false for an
/// open sub-path.
fn delete_segment_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, _) = subpath(segs, si);
if !open_at(segs, si) {
return false;
}
// The deleted segment now ends the sub-path.
let (_, to) = subpath(segs, from);
if to > from + 1 {
segs.remove(to - 1);
}
true
}
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, to) = subpath(segs, si);
let last = to.saturating_sub(1);
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
return false;
}
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
let mut add = Vec::new();
if (start - end).hypot() >= 1e-6 {
add.push(Seg::L(start));
}
add.push(Seg::Z);
segs.splice(to..to, add);
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);
@@ -402,6 +524,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
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;
s.tool.edit_segment = None;
if !on {
s.tool.edit_points = None;
return Ok(json!({"editing": Value::Null}));
@@ -436,17 +559,16 @@ fn points(s: &mut Session, p: &Value) -> Result<Value> {
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}))
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
if let Geom::Custom { paths } = &sh.geom {
for cp in paths {
let segs = parse(&cp.d);
let vs = vertices(&segs);
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
}
}
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
}
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
@@ -502,26 +624,26 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
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 r = 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
};
let si = ending_segment(segs, si).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?;
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
})
})?;
forget_kinds(s, path);
Ok(r)
}
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 r = 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")) }
})
})?;
forget_kinds(s, path);
Ok(r)
}
/// The vertex of `sh` near slide point `p`: (path, index).
@@ -536,6 +658,62 @@ pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh
None
}
/// The point at parameter `t` of segment `si`, scaled by `scale` into the shape's box.
fn segment_point(segs: &[Seg], si: usize, p0: Point, t: f64, scale: (f64, 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 * scale.0, q.y * scale.1))
}
/// The vertex that ends segment `si` (a closing edge ends on the start).
fn segment_end_vertex(segs: &[Seg], vs: &[usize], si: usize) -> Option<usize> {
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)
}
}
/// The distance and `t` of the point on segment `si` nearest `local`, if it lies within `tol`.
fn nearest_on_segment(segs: &[Seg], si: usize, p0: Point, scale: (f64, f64), local: Point, tol: f64) -> Option<(f64, f64)> {
let at = |t: f64| segment_point(segs, si, p0, t, scale);
let mut near: Option<(f64, f64)> = None;
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 && near.is_none_or(|n| d < n.0) {
near = Some((d, t));
}
}
}
let (mut d, mut t) = near?;
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
let mut step = 1.0 / 32.0;
for _ in 0..4 {
step /= 8.0;
let c = t;
for k in -8..=8 {
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
if let Some(q) = at(u) {
let e = (q - local).hypot();
if e < d {
(d, t) = (e, u);
}
}
}
}
Some((d, t))
}
/// 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 };
@@ -543,39 +721,15 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
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 scale = (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));
}
}
let Some(ev) = segment_end_vertex(&segs, &vs, si) else { continue };
let Some((d, t)) = nearest_on_segment(&segs, si, p0, scale, local, tol) else { continue };
if best.is_none_or(|b| d < b.0) {
best = Some((d, pi, ev, t));
}
}
}
@@ -740,7 +894,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
_ => 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()));
s.tool.vertex_kind = Some((id, 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);
@@ -784,3 +938,98 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
move_handle(s, p)
}
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.segmentType")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.segmentType")?;
let curved = match str_param(p, "type") {
Some("straight") => false,
Some("curved") => true,
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
};
// Already that type: nothing to change, so no undo step.
let want = if curved { "curved" } else { "straight" };
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
return ok();
}
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
set_segment(segs, j, curved);
Ok(true)
})
}
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.openPath")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.openPath")?;
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
})?;
forget_kinds(s, path);
Ok(r)
}
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.closePath")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.closePath")?;
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
})?;
forget_kinds(s, path);
Ok(r)
}
fn delete_segment(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.deleteSegment")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.deleteSegment")?;
let r = edit_path(s, id, path, "shape.deleteSegment", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.deleteSegment", "no such segment"))?;
if ending_segment(segs, si).is_none() {
return Err(bad("shape.deleteSegment", "the first point of an open outline ends no segment"));
}
if delete_segment_at(segs, si) { Ok(true) } else { Err(bad("shape.deleteSegment", "only a segment of a closed outline can be deleted")) }
})?;
forget_kinds(s, path);
Ok(r)
}
/// The point where the segment ending at vertex `index` of path `path` of `sh` starts.
pub fn segment_start_of(sh: &Shape, path: usize, index: usize) -> Option<usize> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let vs = vertices(&segs);
let j = ending_segment(&segs, *vs.get(index)?)?;
vs.iter().position(|v| Some(*v) == j.checked_sub(1))
}
/// Adding, deleting, opening or closing renumbers the points of `path`: a point type remembered
/// for one of them would follow the wrong point.
fn forget_kinds(s: &mut Session, path: usize) {
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.1 == path) {
s.tool.vertex_kind = None;
}
}
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
pub fn segment_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)?;
segment_kind(&segs, ending_segment(&segs, si)?)
}
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let si = *vertices(&segs).get(index)?;
let (_, to) = subpath(&segs, si);
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
}
+108 -4
View File
@@ -1,11 +1,12 @@
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
use deckcraft_color::{Rgba, SchemeSlot};
use deckcraft_model::style::PictureAdjust;
use deckcraft_model::{ColorRef, ShapeKind};
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 super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -27,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Transparent Color",
["Picture Format", "Adjust", "Color"],
None,
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
"{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
),
@@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
}
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 {
@@ -134,3 +144,97 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
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
}
+94 -31
View File
@@ -10,22 +10,41 @@ 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 deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
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
)]
vec![
cmd!(
query "slide.reuseList",
"List Slides to Reuse",
[],
None,
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
has_doc,
reuse_list
),
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
),
]
}
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
let src = super::file::open_presentation(&name, &bytes)?;
let slides: Vec<Value> =
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
Ok(json!({"name": name, "slides": slides}))
}
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
@@ -114,9 +133,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
}
/// Fresh shape ids for `shapes`, and the old → new map.
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
let old = shape_ids(shapes);
super::edit::reid("slide.reuse", doc, shapes)?;
super::edit::reid(cmd, doc, shapes)?;
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
remap_connectors(shapes, &map, 0);
Ok(map)
@@ -171,19 +190,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
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)?;
}
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
doc.masters.push(Arc::new(m));
}
}
@@ -192,11 +199,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
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)?;
copy_media("slide.reuse", 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)?;
let map = renumber("slide.reuse", 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));
@@ -227,9 +234,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
/// shape ids. `layouts` gains each source layout id → its copy's.
pub(crate) fn import_master(
cmd: &str,
doc: &mut Presentation,
src: &Presentation,
m: &Master,
media_map: &mut HashMap<MediaId, MediaId>,
layouts: &mut HashMap<LayoutId, LayoutId>,
) -> Result<Master> {
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(cmd, doc, src, &used, 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(cmd, NO_ID_SPACE))?);
renumber(cmd, doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
layouts.insert(l.id, new);
l.id = new;
renumber(cmd, doc, &mut l.shapes)?;
}
Ok(m)
}
/// 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<()> {
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
for &id in used {
if map.contains_key(&id) {
continue;
@@ -237,12 +270,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map:
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))?);
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, 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))?
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
};
map.insert(id, new);
}
@@ -397,4 +430,34 @@ mod tests {
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");
}
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
/// exactly those.
#[test]
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
let mut src = Session::with_new();
for t in ["Alpha", "Beta", "Gamma"] {
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
}
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
let data = super::super::base64_encode(&bytes);
let mut s = Session::with_new();
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
let slides = listed["slides"].as_array().expect("slides");
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
assert!(slides.len() >= 3);
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
let last = slides.len() - 1;
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
}
}
+51 -6
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::Comment;
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
};
use crate::{Result, Session, Target, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -59,6 +61,15 @@ pub fn specs() -> Vec<CommandSpec> {
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.reject",
"Reject Revision",
["Review", "Compare"],
None,
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
comparing,
reject
),
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, _| {
@@ -421,7 +432,7 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
let end = start + word.chars().count();
let end = start.checked_add(word.chars().count()).ok_or_else(|| bad("review.spellingChange", "bad `start`"))?;
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
let slide = {
let st = s.doc()?;
@@ -655,7 +666,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
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}))
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
@@ -663,7 +674,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
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 });
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
@@ -673,7 +684,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
}
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 cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
let rev = cmp.revised.clone();
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
@@ -684,7 +696,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
// 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 changes = crate::compare::pending(doc, &cmp);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
@@ -707,6 +719,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.reject", "give `index` or `all`"));
}
let st = s.doc_mut()?;
let doc = st.doc.clone();
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
let pending = crate::compare::pending(&doc, c);
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
if picked.is_empty() {
return Err(bad("review.reject", "no such revision"));
}
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
Ok(json!({"rejected": picked.len()}))
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
@@ -815,3 +845,18 @@ mod tests {
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
}
}
#[cfg(test)]
mod spelling_change_range_tests {
use crate::{EngineError, Session};
use serde_json::json;
#[test]
fn spelling_change_huge_start_is_bad_params() {
let mut s = Session::with_new();
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
let id = r["id"].clone();
let err = s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": u64::MAX, "word": "Hello", "to": "x"})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
}
+119 -19
View File
@@ -107,14 +107,16 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty()
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
{
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
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);
@@ -130,7 +132,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
bold += db;
italic += di;
underline += du;
if !token.ends_with('/') {
if !token.ends_with('/') && stack.len() < 128 {
stack.push((tag_kind(&name), db, di, du));
} else {
bold -= db;
@@ -145,8 +147,13 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
continue;
}
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
let text = decode_text(&html[i..end]);
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;
}
@@ -155,6 +162,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
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;
}
@@ -223,7 +231,11 @@ fn weight_bold(style: &str) -> Option<bool> {
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let at = lower.match_indices(key).find_map(|(at, _)| {
let named = lower[..at].ends_with(|c: char| c.is_ascii_whitespace());
let eq = tag.get(at + key.len()..)?.trim_start().starts_with('=');
(named && eq).then_some(at)
})?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
@@ -240,10 +252,19 @@ fn new_para(paragraphs: &mut Vec<Paragraph>) {
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;
@@ -263,15 +284,38 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold:
*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(&rest[..at].replace(['\n', '\r'], ""));
out.push_str(&collapse_space(&rest[..at]));
rest = &rest[at..];
let Some(end) = rest.find(';') else {
out.push_str(&rest.replace(['\n', '\r'], ""));
break;
let mut search_window = rest.len().min(32);
while !rest.is_char_boundary(search_window) {
search_window -= 1;
}
let Some(end) = rest[..search_window].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
};
let entity = &rest[1..end];
let decoded = match entity {
@@ -280,7 +324,7 @@ fn decode_text(raw: &str) -> String {
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"nbsp" => Some('\u{a0}'),
_ => entity.strip_prefix('#').and_then(numeric_char),
};
if let Some(c) = decoded {
@@ -293,15 +337,20 @@ fn decode_text(raw: &str) -> String {
}
}
if !rest.is_empty() && !rest.starts_with('&') {
out.push_str(&rest.replace(['\n', '\r'], ""));
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')
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
return None;
}
let n = u32::from_str_radix(body, radix).ok()?;
char::from_u32(n).filter(|c| !c.is_control() || matches!(*c, '\n' | '\t'))
}
/// Do the HTML's words and the paste's plain text say the same thing?
@@ -319,6 +368,27 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn multibyte_after_ampersand_is_kept() {
let text = "é".repeat(20);
assert_eq!(decode_text(&format!("R&D {text}")), format!("R&D {text}"));
let cjk = "漢".repeat(20);
assert_eq!(decode_text(&format!("&{cjk}")), format!("&{cjk}"));
}
#[test]
fn signed_numeric_entities_are_kept_as_typed() {
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
assert_eq!(decode_text("a&#65;b&#x41;"), "aAbA");
}
#[test]
fn numeric_tab_entity_decodes_to_tab() {
assert_eq!(decode_text("a&#9;b"), "a\tb");
assert_eq!(decode_text("a&#x9;b"), "a\tb");
}
#[test]
fn google_docs_normal_weight_wrapper_is_not_bold() {
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
@@ -333,6 +403,20 @@ mod tests {
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn style_attribute_is_found_after_attributes_containing_style() {
for tag in [
"<span class=\"style1\" style=\"font-weight:bold\">x</span>",
"<span data-style=\"x\" style=\"font-weight:bold\">x</span>",
"<span class=\"mystyle\" style=\"font-weight:bold\">x</span>",
] {
let body = html_to_body(tag).unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{tag}");
}
let none = html_to_body("<span class=\"style\">x</span>").unwrap();
assert_eq!(none.paragraphs[0].runs[0].props.bold, None);
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
@@ -358,6 +442,10 @@ mod tests {
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]
@@ -371,6 +459,18 @@ mod tests {
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);
+1 -1
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{Shape, ShapeId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -2,8 +2,8 @@
use serde_json::{Value, json};
use crate::Session;
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::*;
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
fn deck() -> (Session, Vec<u32>) {
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
while adj.len() <= i {
let k = adj.len();
adj.push(defaults.get(k).copied().unwrap_or(0.0));
}
if let Some(slot) = adj.get_mut(i) {
*slot = v;
}
}
Ok(())
})
}
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
pub(super) fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "shape.smoothCorners";
let smoothing =
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
edit_geometry(s, p, CMD, |sh| {
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
let ss = x.w.min(x.h).max(0.0);
let own = match &sh.geom {
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
_ => None,
};
// A custom outline qualifies only when it is a squircle this command drew with `radius`
// (re-smoothing as the slider moves), never an arbitrary freeform.
let custom = match (&sh.geom, radius) {
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
_ => false,
},
_ => false,
};
let r = match (radius, own) {
(Some(r), _) if own.is_some() || custom => r,
(None, Some(r)) => r,
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
};
sh.geom = Geom::Custom {
paths: vec![deckcraft_model::CustomPath {
w: x.w,
h: x.h,
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
..Default::default()
}],
};
Ok(())
})
}
pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
edit_geometry(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
}
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
.as_array()?
.iter()
.filter_map(|s| {
let a = s.as_array()?;
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
})
.collect();
if stops.len() < 2 {
return None;
}
let focus = v
.get("focus")
.and_then(Value::as_array)
.and_then(|a| {
let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
Some([f(0)?, f(1)?, f(2)?, f(3)?])
})
.unwrap_or([0.5, 0.5, 0.5, 0.5]);
let mut stops = stops;
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
let path = |p: &str| GradientShape::Path { path: p.into(), focus };
let shape = match v.get("kind").and_then(Value::as_str) {
Some("radial" | "circle") => path("circle"),
Some("rectangular" | "rect") => path("rect"),
Some("path" | "shape") => path("shape"),
_ => GradientShape::Linear {
angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0),
scaled: false,
},
};
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
}
/// Fill and outline apply to a group's visible members as well as its container. Other edits
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
if depth > 64 {
return Ok(());
}
f(sh)?;
if let Some(children) = sh.children_mut() {
for child in children {
apply(child, depth + 1, f)?;
}
}
Ok(())
}
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
}
pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let new_fill = requested_fill(s, p)?;
let transparency = f64_param(p, "transparency");
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
}
// Transparency on an inherited (style) fill needs the effective colour.
let inherited = inherited_fills(s, p)?;
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
}
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
pub(super) fn picture_props(pf: &mut PictureFill, pp: &Value) {
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
}
}
fn line_end(v: &str) -> Option<LineEnd> {
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
}
pub(super) fn line(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let none = bool_or(p, "none", false);
let color = color_param(p, "color");
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
let head = str_param(p, "head").map(line_end);
let tail = str_param(p, "tail").map(line_end);
let cap = str_param(p, "cap").map(|c| match c {
"round" => deckcraft_model::style::LineCap::Round,
"square" => deckcraft_model::style::LineCap::Square,
_ => deckcraft_model::style::LineCap::Flat,
});
let compound = str_param(p, "compound").map(|c| match c {
"double" => deckcraft_model::style::Compound::Double,
"thickThin" => deckcraft_model::style::Compound::ThickThin,
"thinThick" => deckcraft_model::style::Compound::ThinThick,
"triple" => deckcraft_model::style::Compound::Triple,
_ => deckcraft_model::style::Compound::Single,
});
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
let join = str_param(p, "join").map(|c| match c {
"bevel" => deckcraft_model::style::LineJoin::Bevel,
"miter" => deckcraft_model::style::LineJoin::Miter,
_ => deckcraft_model::style::LineJoin::Round,
});
edit_group_appearance(s, p, "shape.line", |sh| {
if reset {
sh.line = None;
return Ok(());
}
let l = sh.line.get_or_insert_with(Line::default);
if none {
l.fill = Some(Fill::None);
}
if let Some(c) = &color {
l.fill = Some(Fill::solid(c.clone()));
}
if width.is_some() {
l.width = width;
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
}
}
if dash.is_some() {
l.dash.clone_from(&dash);
}
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
new.clone().map(|mut n| {
if let Some(o) = old {
n.w.clone_from(&o.w);
n.len.clone_from(&o.len);
}
n
})
};
if let Some(h) = &head {
l.head = keep_size(&l.head, h);
}
if let Some(t) = &tail {
l.tail = keep_size(&l.tail, t);
}
if cap.is_some() {
l.cap = cap;
}
if join.is_some() {
l.join = join;
}
if compound.is_some() {
l.compound = compound;
}
// Arrowhead size (width and length together), kept when the kind changes.
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
e.w.clone_from(sz);
e.len.clone_from(sz);
}
}
Ok(())
})
}
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
let base = |inner: bool| Shadow {
color: black(40000),
blur: 4.0,
dist: 3.0,
dir: 45.0,
inner,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: "tl".into(),
rotate_with_shape: false,
};
match v {
Value::String(s) => Some(match s.as_str() {
"none" => None,
"inner" => Some(base(true)),
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
_ => Some(base(false)),
}),
Value::Object(o) => Some(Some(Shadow {
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
..base(false)
})),
_ => None,
}
}
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let shadow = p.get("shadow").and_then(shadow_preset);
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
Value::Object(o) => Some(Glow {
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
}),
Value::Number(n) => Some(Glow {
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
}),
_ => None,
});
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned();
let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned();
let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned();
edit_shapes(s, p, "shape.effects", |sh| {
if reset {
sh.effects = None;
return Ok(());
}
let e = sh.effects.get_or_insert_with(Effects::default);
if let Some(sw) = &shadow {
match sw {
Some(x) if x.inner => {
e.inner_shadow = Some(x.clone());
e.outer_shadow = None;
}
Some(x) => {
e.outer_shadow = Some(x.clone());
e.inner_shadow = None;
}
None => {
e.outer_shadow = None;
e.inner_shadow = None;
}
}
}
if let Some(g) = &glow {
e.glow.clone_from(g);
}
if let Some(sf) = soft {
e.soft_edge = sf;
}
if let Some(r) = &refl {
e.reflection.clone_from(r);
}
if bevel.is_some() || three_d.is_some() {
let mut s3 = e.raw3d.as_deref().map(crate::cmd::three_d::Shape3d::parse).unwrap_or_default();
if let Some(b) = &bevel {
s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")}));
}
if let Some(t) = three_d {
if t.get("reset").and_then(Value::as_bool) == Some(true) {
s3 = crate::cmd::three_d::Shape3d::default();
}
s3.apply(t);
}
crate::cmd::three_d::store(e, &s3);
}
if let Some(v) = &shadow_props {
let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow };
let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?;
if let Some(c) = v.get("color").and_then(color_value) {
let a = x.color.alpha();
x.color = with_alpha(c, a);
}
if let Some(t) = num(v, "transparency") {
x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0));
}
if let Some(b) = num(v, "blur") {
x.blur = b.clamp(0.0, 100.0);
}
if let Some(d) = num(v, "dist") {
x.dist = d.clamp(0.0, 200.0);
}
if let Some(d) = num(v, "dir") {
x.dir = d.rem_euclid(360.0);
}
*slot = Some(x);
}
if let Some(v) = &glow_props {
let mut g =
e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 });
if let Some(c) = v.get("color").and_then(color_value) {
let a = g.color.alpha();
g.color = with_alpha(c, a);
}
if let Some(t) = num(v, "transparency") {
g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0));
}
if let Some(r) = num(v, "radius") {
g.radius = r.clamp(0.0, 150.0);
}
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
if let Some(sz) = num(v, "size") {
r.end_pos = sz.clamp(0.0, 1.0);
}
if let Some(d) = num(v, "dist") {
r.dist = d.clamp(0.0, 100.0);
}
if let Some(b) = num(v, "blur") {
r.blur = b.clamp(0.0, 100.0);
}
e.reflection = Some(r);
}
Ok(())
})
}
/// A finite number at `key` of an effect's properties.
fn num(v: &Value, key: &str) -> Option<f64> {
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
}
/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform.
fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef {
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if a < 1.0 {
c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32));
}
c
}
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
let slots = [
SchemeSlot::Dk1,
SchemeSlot::Accent1,
SchemeSlot::Accent2,
SchemeSlot::Accent3,
SchemeSlot::Accent4,
SchemeSlot::Accent5,
SchemeSlot::Accent6,
];
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
let row = index / 7;
let mut st = ShapeStyle::accent(slot);
let c = ColorRef::scheme(slot);
match row {
0 => {
// Colored outline, light fill.
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
st.line_ref = (2, c);
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
(st, None, None)
}
1 => (st, None, None),
2 => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
st.line_ref = (0, c.clone());
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
(st, None, Some(Line::none()))
}
3 => {
st.fill_ref = (2, c);
(st, None, Some(Line::none()))
}
4 => {
st.fill_ref = (3, c.clone());
st.effect_ref = (3, c);
(st, None, Some(Line::none()))
}
_ => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
(st, None, None)
}
}
}
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone());
sh.fill.clone_from(&f);
sh.line.clone_from(&l);
sh.effects = None;
if let Some(t) = sh.text.as_mut() {
deckcraft_model::edit::format_all(t, &|r| r.fill = None);
}
Ok(())
})
}

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