diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f0d6d822..3299a639 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -7,6 +7,8 @@ fails if a file under `assets/`, `docs/images/` or `docs/brand/` is missing from DeckCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, fonts, themes, templates or other assets**. Its themes, colour schemes, layouts, preset-shape geometry, sample deck and UI icons are original work of the DeckCraft contributors (drawn or generated in code). +Icons placed from Insert › Icons are Lucide paths (ISC; Feather-derived icons MIT), not Microsoft +artwork. The ribbon and menu icons stay original drawings. ## In this repository @@ -26,11 +28,14 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots | | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | +| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang | +| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | | `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | | `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | | `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font | +| Lucide icon paths in `crates/engine/src/icon_data.rs` (Insert › Icons) | Lucide Contributors; Feather icons by Cole Bemis | https://github.com/lucide-icons/lucide/tree/440a1e1dfb1e712505308da46bead3c28b1f1398/icons (release 1.56.0, 2026-10-13) | ISC; MIT for the Feather-derived icons named in that commit's LICENSE | Stroke icons inserted as shapes | ## Bundled through dependencies diff --git a/Cargo.lock b/Cargo.lock index e91dbf00..e2bc7965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "arboard", "cpal", @@ -911,7 +911,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -929,14 +929,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.6.1" +version = "0.7.0" dependencies = [ "proptest", ] [[package]] name = "deckcraft-cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -945,15 +945,16 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.6.1" +version = "0.7.0" dependencies = [ + "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", "deckcraft-format", @@ -966,15 +967,17 @@ dependencies = [ "deckcraft-text", "i_overlay", "image", + "js-sys", "log", "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] name = "deckcraft-fonts" -version = "0.6.1" +version = "0.7.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -986,7 +989,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-model", "serde", @@ -997,7 +1000,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.6.1" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1005,7 +1008,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1014,7 +1017,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1023,7 +1026,7 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "thiserror 2.0.21", @@ -1031,11 +1034,11 @@ dependencies = [ [[package]] name = "deckcraft-matroska" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-mcp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1045,7 +1048,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1063,15 +1066,16 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.6.1" +version = "0.7.0" dependencies = [ + "image", "kurbo", "thiserror 2.0.21", ] [[package]] name = "deckcraft-model" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1083,15 +1087,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-opus" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-pdf" -version = "0.6.1" +version = "0.7.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1099,6 +1103,7 @@ dependencies = [ "deckcraft-render", "deckcraft-text", "hayro-syntax", + "krilla", "kurbo", "log", "serde", @@ -1108,7 +1113,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1116,7 +1121,7 @@ dependencies = [ "deckcraft-model", "image", "log", - "quick-xml 0.38.4", + "quick-xml", "serde_json", "thiserror 2.0.21", "zip", @@ -1124,7 +1129,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1143,7 +1148,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.6.1" +version = "0.7.0" dependencies = [ "chrono", "deckcraft-color", @@ -1157,7 +1162,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1179,7 +1184,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1188,7 +1193,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -2432,6 +2437,7 @@ dependencies = [ "indexmap", "pdf-writer", "png", + "rayon", "rustc-hash 2.1.3", "rustybuzz", "siphasher", @@ -3455,15 +3461,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -4937,7 +4934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] @@ -5723,7 +5720,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 03d475c8..c9cb8182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,7 +59,7 @@ 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"] } i_overlay = "9" -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # ui diff --git a/NOTICE b/NOTICE index 1ebe2acd..79a0babb 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,51 @@ list (author, source, license) is in ATTRIBUTION.md. ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages. +- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0, + commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide. + ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather + are MIT, Copyright (c) 2013-present Cole Bemis; which names those are is the list in Lucide's + LICENSE at that commit. Among the paths embedded here, the Feather-derived names are arrow-down, + arrow-left, arrow-right, arrow-up, at-sign, calendar, check, chevron-right, clipboard, clock, + compass, download, info, key, link, lock, minus, moon, move, music, plus, search, target and x. + + ISC License + + Copyright (c) 2026 Lucide Icons and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + The MIT License (MIT) (for the Feather-derived icons) + + Copyright (c) 2013-present Cole Bemis + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. Code dependencies diff --git a/README.md b/README.md index cffc5de7..82903b44 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Status: early development License: MIT OR Apache-2.0 Written in Rust - Platforms + Platforms

@@ -75,7 +75,7 @@ Today it can: - **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations, by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback. - **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts - with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover. + with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover. - **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an MCP server for AI agents. @@ -153,11 +153,12 @@ deckcraft-cli commands format. # list commands deckcraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.deckcraft deckcraft-cli mcp # MCP server over stdio (headless) deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --control 7990) +deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # confine the headless agent to folders ``` ## Roadmap -DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core +DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, @@ -193,6 +194,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed ("Parent device is lost" at start, #153). `$env:WGPU_DX12_COMPILER = "dxc"` +(or `"auto"`) selects wgpu's DXC handling again for troubleshooting. + ### macOS | Build | File | Notes | diff --git a/ROADMAP.md b/ROADMAP.md index d75a8a03..ce8c4a9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -14,8 +14,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| -| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | -| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | +| **Feature breadth** | **84%** weighted over 191 PowerPoint features (P0 92%, P1 82%, P2 55%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | +| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands at d556ff9 (the registry has grown since; not re-measured) | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | | Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | @@ -45,7 +45,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Dimension | Ready | Hours to full | Doc | |---|---:|---|---| -| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | +| Features (breadth 84% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | | UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) | | File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) | | Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) | @@ -61,26 +61,26 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Area | Breadth (measured) | Ready (est.) | Hours | Doc | |---|---:|---:|---|---| -| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) | -| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) | -| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) | -| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) | -| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) | -| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) | +| Application shell | 81% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) | +| Slides and sections | 92% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) | +| Views | 98% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) | +| Masters and themes | 92% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) | +| Shapes | 91% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) | +| Format (fill, line, effects) | 83% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) | | Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) | -| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) | -| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) | +| Text | 83% | ~62% | 18–30 | [gaps](docs/gaps.md#features) | +| Tables | 97% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) | | Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) | -| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) | -| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) | +| SmartArt | 50% | ~35% | 8–14 | [gaps](docs/gaps.md#smartart-editing-is-shallow) | +| Pictures | 60% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) | | Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) | -| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) | -| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | -| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) | -| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) | -| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) | -| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) | -| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) | +| Transitions | 100% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) | +| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | +| Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) | +| Review | 70% | ~45% | 8–14 | [gaps](docs/gaps.md) | +| Draw (ink) | 50% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) | +| Undo and clipboard | 100% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) | +| Files (commands; formats above) | 75% | ~50% | in file formats | [file formats](docs/file-format-parity.md) | ## Languages @@ -111,8 +111,11 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md). 1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59), image paste (#53), editor soak tests. 10–20 h. -2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG. - 36–64 h. +2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom + shows, rotated groups, inherited placeholders, text effects and body layout, theme effects, + fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models + and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records, + MicroType Express font subsets and SVG. 36–64 h. 3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h. 4. **Output:** vector PDF, printing. 10–16 h. 5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline, @@ -130,9 +133,75 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---| | 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) | | 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production | +| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) | +| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) | +| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) | +| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 | +| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape | +| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped | +| 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in | +| 2026-10-13 | Codec parsers (#426): H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic, so a hostile number is a format error instead of an overflow | +| 2026-10-13 | WordArt Transform: Shape Format ▸ Text Effects shows the 40 text warps and No Transform; `format.textWarp` applies one to the selected text. Adjust handles are still open | +| 2026-10-13 | PPTX corpus round-trip for decks we author (open → save → reopen, package parts and text). A notes master's header and footer are kept instead of being replaced by an empty notes master | +| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open | +| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open | +| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open | +| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit | +| 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp | +| 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design | +| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu | +| 2026-10-14 | Review: Previous and Next comment walk every comment in the deck; the Comments pane lists every slide and highlights the one you landed on | +| 2026-10-13 | View › New Window opens a second window on the same presentation. The window has its own slide and shows edits made in the first window. Arrange All is not included | +| 2026-10-14 | File › New starter decks: Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk, original text on our themes (`file.new {template}`, `file.templates`). The large template gallery is still open | +| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides | +| 2026-10-14 | Text: an input method's composition in a table cell is underlined in place, with the caret and candidate window in that cell | +| 2026-10-14 | Edit Photo Album (`insert.photoAlbumEdit`): reopen the album's slides and re-flow them in one undo step, with black and white, rotation, brightness, contrast and an album theme | +| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step | +| 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo | +| 2026-10-14 | Design › Fonts › Customize Fonts… opens Create New Theme Fonts: the open master's scheme name and its heading and body latin faces, applied in one undo | +| 2026-10-14 | Alt Text: Mark as decorative opens checked when the selection already is, so OK no longer clears it | +| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip | +| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time | +| 2026-10-14 | View: the Zoom dialog takes a percent or Fit and applies it on OK. View ▸ Zoom opens it; the arrow still lists the presets | +| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept | +| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by | +| 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial. The presenter window does not move the dot | +| 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show | +| 2026-10-14 | During a show, a typed slide number is shown and Enter goes to that slide | +| 2026-10-14 | A custom show plays only its slides: Show in the Custom Shows dialog, Set Up Slide Show, or a click action | +| 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433 | +| 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom | +| 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) | +| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that | +| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` | +| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`) | +| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero | +| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 | +| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) | +| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) | +| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Checklist row Preview in editor re-scored done | +| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | +| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged | +| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency | +| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) | +| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step | +| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step | +| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | +| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | +| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it | +| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | +| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected | +| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) | +| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) | +| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | +| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | +| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | +| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) | +| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -141,6 +210,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | +| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line | +| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | @@ -152,9 +223,63 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | +| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) | +| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage | +| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls | +| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions | +| 2026-10-13 | trivial | Progress log: embedded fonts | +| 2026-10-13 | trivial | Progress log: codec parsers reject overflowing numeric fields (#426) | +| 2026-10-13 | trivial | Progress log: owned PPTX corpus round-trip; notes-master header and footer | +| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders | +| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors | +| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end | +| 2026-10-14 | trivial | Progress log: chart trendlines are drawn | +| 2026-10-14 | trivial | Progress log: chart trendlines | +| 2026-10-14 | trivial | Progress log: Lucide icon gallery | +| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% | +| 2026-10-14 | trivial | Progress log: previous and next comment | +| 2026-10-13 | trivial | Progress log: View › New Window | +| 2026-10-14 | trivial | Progress log: six original starter decks | +| 2026-10-14 | trivial | Progress log: Slide Sorter sections | +| 2026-10-14 | trivial | Progress log: IME underlines in table cells | +| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% | +| 2026-10-14 | trivial | Progress log: Edit Photo Album. Breadth stays 79%, ready ~55% | +| 2026-10-14 | trivial | Progress log: Create New Theme Colors | +| 2026-10-14 | trivial | Progress log: Create New Theme Fonts | +| 2026-10-14 | trivial | Progress log: Alt Text decorative checkbox | +| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection | +| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) | +| 2026-10-14 | trivial | Progress log: Zoom dialog | +| 2026-10-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text | +| 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) | +| 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) | +| 2026-10-14 | trivial | Progress log: Show Media Controls | +| 2026-10-14 | trivial | Progress log: go to slide shows the typed number. Breadth stays 79% | +| 2026-10-14 | trivial | Progress log: custom show playback. Breadth stays 79% | +| 2026-10-14 | trivial | Progress log: presenter timer pause/reset, clock and notes size. Breadth stays 79% | +| 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% | +| 2026-10-13 | trivial | Progress log: command palette Export (#79) | +| 2026-10-14 | trivial | Progress log: Design ▸ Effects | +| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged | +| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection | +| 2026-10-14 | trivial | Progress log: Format Background picture and solid options | +| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) | +| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps | +| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% | +| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection | +| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) | +| 2026-10-14 | trivial | Progress log: Paragraph dialog | +| 2026-10-14 | trivial | Progress log: Font dialog | +| 2026-10-13 | trivial | Progress log: Grid Options dialog | +| 2026-10-14 | trivial | Progress log: pen pressure scales ink width | +| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) | | 2026-10-13 | trivial | Progress log: wild text cases | +| 2026-10-13 | trivial | Progress log: slide show for interactive decks | +| 2026-10-12 | minor | Progress log: table cell editing and border resizing | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | +| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | diff --git a/apps/deckcraft-cli/src/automation_args.rs b/apps/deckcraft-cli/src/automation_args.rs new file mode 100644 index 00000000..8f0cabf8 --- /dev/null +++ b/apps/deckcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}=

for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--sample", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("deck.pptx")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..535ce848 --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -0,0 +1,185 @@ +pub struct Subcommand { + pub name: &'static str, + pub usage: &'static str, + pub summary: &'static str, + pub details: &'static str, +} + +pub const SUBCOMMANDS: &[Subcommand] = &[ + Subcommand { + name: "info", + usage: "info FILE", + summary: "print a presentation's slides, layouts and theme as JSON", + details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes, +notes, transition), its layouts, theme fonts, sections and slide size. + +The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command: + + deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'", + }, + Subcommand { + name: "render", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + summary: "draw slides as PNG images", + details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; +OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per +slide. Prints each image's path and size. + + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + + deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "render-transition", + usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png", + summary: "draw frames of the transition into a slide as PNG images", + details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N), +as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as +OUT-t0.250.png and so on. Prints each image's path, size, transition and time. + + --slide N the slide the transition leads into; indices start at 0 + --t T,T... the times to draw, comma-separated + --width PX the image width in pixels, twice the slide width in points by default + + deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png", + }, + Subcommand { + name: "convert", + usage: "convert IN OUT", + summary: "save a presentation in the format OUT's extension names", + details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an +animated GIF with one frame per slide. Other extensions are refused. Prints OUT. + + deckcraft-cli convert deck.deckcraft deck.pptx", + }, + Subcommand { + name: "run", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", + summary: "run commands on a presentation, then save or export it", + details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, +else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps +everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the +current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per +slide). --export-params passes file.export options as one JSON object, such as +'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF +(`deckcraft-cli describe file.export` lists them). + +Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one +JSON object. Quote it for the shell. A command that needs no parameters is just its id. + + deckcraft-cli run \\ + --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ + --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ + --cmd 'format.size={\"size\":28}' \\ + --save q3.deckcraft + +`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its +parameters. + +What the ids don't say: +- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is + [x, y, width, height]. +- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current; + slide.go {\"index\": N} makes slide N current. +- Shape ids are looked up on the current slide only. +- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts + on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands + take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on. + +If a command fails, run stops there and saves nothing. --print shows each command's answer, such as +the id of an inserted shape.", + }, + Subcommand { + name: "commands", + usage: "commands [FILTER]", + summary: "list the commands run can use, one per line", + details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in +the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case. + + deckcraft-cli commands format. + +In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what +the command answers. `deckcraft-cli describe COMMAND` spells one command out.", + }, + Subcommand { + name: "describe", + usage: "describe COMMAND", + summary: "explain one command and its parameters", + details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run +calls it. + + deckcraft-cli describe shape.insert", + }, + Subcommand { + name: "app", + usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]", + summary: "run a command in the DeckCraft app started with --control", + details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default) +and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD +[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object. + + deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}' + deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'", + }, + Subcommand { + name: "mcp", + usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]", + summary: "serve the commands to an agent over MCP on stdio", + details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a +window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. +--sample opens the sample presentation. + +--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read +(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left +out grants none of its access. They run the in-process server, so they conflict with --connect (a running +app is not confined by them).", + }, + Subcommand { + name: "bench", + usage: "bench (FILE | --sample) [--runs N] [--json]", + summary: "time opening, drawing, saving and exporting a presentation", + details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to +save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the +median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json. + + deckcraft-cli bench deck.pptx --runs 10", + }, + Subcommand { + name: "links", + usage: "links", + summary: "print the DeckCraft community and project links", + details: "Prints the DeckCraft community and project links.", + }, + Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." }, +]; + +pub fn page(name: &str) -> Option { + let name = match name { + "help" | "--help" | "-h" => return Some(overview()), + "--version" | "-V" => "version", + name => name, + }; + SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details)) +} + +pub fn overview() -> String { + let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default(); + let list: Vec = + SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode { let args: Vec = std::env::args().skip(1).collect(); let rest = args.get(1..).unwrap_or(&[]); + let asks_help = rest.iter().any(|a| a == "--help" || a == "-h"); + if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) { + outln!("{page}"); + return ExitCode::SUCCESS; + } match args.first().map(String::as_str) { + Some("help" | "--help" | "-h") => match rest.first() { + None => { + outln!("{}", help::overview()); + ExitCode::SUCCESS + } + Some(name) => match help::page(name) { + Some(page) => { + outln!("{page}"); + ExitCode::SUCCESS + } + None => unknown(name), + }, + }, Some("--version" | "-V" | "version") => { outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS } Some("info") => report(info(rest)), + Some("bench") => report(bench(rest)), Some("render") => report(render(rest)), Some("render-transition") => report(render_transition(rest)), Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), Some("commands") => { - let s = Session::new(); let f = rest.first().map(|x| x.to_lowercase()); - let list: Vec = s - .commands() - .into_iter() + for c in deckcraft_engine::command_specs() + .iter() .filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str()))) - .map(|c| serde_json::to_value(c).unwrap_or_default()) - .collect(); - outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + { + match c.params.strip_prefix("{}") { + Some(answer) => outln!("{} — {}{answer}", c.id, c.label), + None => outln!("{} — {}: {}", c.id, c.label, c.params), + } + } ExitCode::SUCCESS } Some("describe") => report(describe(rest.first().map(String::as_str))), @@ -77,14 +77,31 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } - _ => { - eprintln!("{USAGE}"); - eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); - ExitCode::FAILURE - } + Some(other) => unknown(other), + None => usage(), } } +fn is_help(arg: &str) -> bool { + matches!(arg, "--help" | "-h") +} + +/// The help page of subcommand `name`, else the overview. +fn help_page(name: &str) -> String { + help::page(name).unwrap_or_else(help::overview) +} + +fn unknown(sub: &str) -> ExitCode { + eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n"); + usage() +} + +fn usage() -> ExitCode { + eprintln!("{}", help::overview()); + eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); + ExitCode::FAILURE +} + fn report(r: Result<(), String>) -> ExitCode { match r { Ok(()) => ExitCode::SUCCESS, @@ -105,7 +122,113 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String } } +/// Median and slowest of `runs` timings of `f`, in milliseconds. +fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> { + let mut ms = Vec::with_capacity(runs); + for _ in 0..runs.max(1) { + let t = std::time::Instant::now(); + f()?; + ms.push(t.elapsed().as_secs_f64() * 1000.0); + } + ms.sort_by(f64::total_cmp); + let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0); + Ok((median, ms.last().copied().unwrap_or(0.0))) +} + +/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere. +fn peak_rss_mib() -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; + Some(kb / 1024.0) +} + +/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to +/// render each slide, to save and to export, and the peak memory, as JSON or a table. +fn bench(args: &[String]) -> Result<(), String> { + use deckcraft_engine::cmd::file::{open_presentation, save_bytes}; + let mut input = None; + let mut sample = false; + let mut runs = 5usize; + let mut as_json = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--sample" => sample = true, + "--json" => as_json = true, + "--runs" => { + runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")? + } + f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()), + o => return Err(format!("bench: unknown argument `{o}`")), + } + } + if input.is_none() && !sample { + return Err("bench needs a FILE or --sample".into()); + } + let mut s = Session::new(); + let (name, bytes) = match &input { + Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?), + None => { + open(&mut s, None, true)?; + let doc = &s.doc().map_err(|e| e.to_string())?.doc; + ("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?) + } + }; + let e = |x: deckcraft_engine::EngineError| x.to_string(); + let doc = open_presentation(&name, &bytes).map_err(e)?; + let slides = doc.slides.len(); + let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?; + let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() }; + let mut per_slide = vec![]; + for i in 0..slides { + per_slide.push(time_ms(runs, || { + deckcraft_engine::render::render_slide(&doc, i, &opts); + Ok(()) + })?); + } + let pptx = save_bytes(&doc, "pptx").map_err(e)?; + let mut steps = vec![ + ("open", open), + ("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?), + ("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?), + ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?), + ("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), + ]; + let render_total: f64 = per_slide.iter().map(|m| m.0).sum(); + let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max); + steps.insert(1, ("render all slides (1×)", (render_total, render_max))); + let peak = peak_rss_mib(); + if as_json { + let v = json!({ + "file": name, + "bytes": bytes.len(), + "slides": slides, + "runs": runs, + "steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::>(), + "renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::>(), + "peakRssMiB": peak, + }); + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + } else { + outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024); + for (k, (m, x)) in &steps { + outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)"); + } + if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) { + outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1); + } + if let Some(p) = peak { + outln!(" peak memory {p:>9.1} MiB"); + } + } + Ok(()) +} + fn info(args: &[String]) -> Result<(), String> { + if args.first().is_some_and(|a| is_help(a)) { + outln!("{}", help_page("info")); + return Ok(()); + } let path = args.first().ok_or("info needs a FILE")?; let mut s = Session::new(); open(&mut s, Some(path), false)?; @@ -130,6 +253,11 @@ fn render(args: &[String]) -> Result<(), String> { "--all" => all = true, "--edit" => edit = true, "--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, + "--help" | "-h" => { + outln!("{}", help_page("render")); + return Ok(()); + } + other if other.starts_with('-') => return Err(format!("unknown option `{other}`")), other if input.is_none() && !sample @@ -148,14 +276,11 @@ fn render(args: &[String]) -> Result<(), String> { open(&mut s, input.as_deref(), sample)?; let st = s.doc().map_err(|e| e.to_string())?; let n = st.doc.slides.len(); - let list: Vec = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] }; + let list = slide_indices(n, all, slide)?; if all { std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?; } for i in list { - if i >= n { - return Err(format!("no slide {i} (the deck has {n})")); - } let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit); let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() }; std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; @@ -164,6 +289,32 @@ fn render(args: &[String]) -> Result<(), String> { Ok(()) } +/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses. +/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number. +fn slide_indices(n: usize, all: bool, slide: Option) -> Result, String> { + if all { + return Ok((0..n).collect()); + } + let i = slide.unwrap_or(0); + if i >= n { + return Err(missing_slide(i, n)); + } + Ok(vec![i]) +} + +fn missing_slide(i: usize, n: usize) -> String { + match n { + 0 => format!("no slide index {i} (this deck has no slides)"), + 1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"), + n => { + let last = n - 1; + format!( + "no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)" + ) + } + } +} + /// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at /// the given progress values. With several values, OUT gets `-t` before its extension. fn render_transition(args: &[String]) -> Result<(), String> { @@ -200,6 +351,7 @@ fn render_transition(args: &[String]) -> Result<(), String> { if ts.is_empty() { return Err("render-transition needs --t".into()); } + let paths = frame_paths(&out, &ts)?; let mut s = Session::new(); open(&mut s, Some(&input), false)?; let st = s.doc().map_err(|e| e.to_string())?; @@ -217,34 +369,51 @@ fn render_transition(args: &[String]) -> Result<(), String> { (prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts) }); let aspect = sw / doc.slide_size.height.max(1.0); - for t in &ts { + for (t, path) in ts.iter().zip(&paths) { let img = match &cached { Some((old, new, parts)) => { render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect)) } None => render::render_transition(doc, i, *t, &opts), }; - let path = if ts.len() == 1 { - out.clone() - } else { - let p = std::path::Path::new(&out); - let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); - let name = format!("{stem}-t{t:.3}.png"); - p.with_file_name(name).to_string_lossy().into_owned() - }; - std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; + std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?; outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option); } Ok(()) } +/// Output path of each frame. A single frame goes to OUT; several get `-t` (three decimals) +/// before the extension. Values that round to the same name would overwrite each other, so that is an error. +fn frame_paths(out: &str, ts: &[f64]) -> Result, String> { + if ts.len() == 1 { + return Ok(vec![out.to_string()]); + } + let p = std::path::Path::new(out); + let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); + let mut paths: Vec = Vec::with_capacity(ts.len()); + for t in ts { + let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned(); + if paths.contains(&path) { + return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}")); + } + paths.push(path); + } + Ok(paths) +} + fn convert(args: &[String]) -> Result<(), String> { + if args.first().is_some_and(|a| is_help(a)) { + outln!("{}", help_page("convert")); + return Ok(()); + } let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; + let fmt = output_format(out)?; let mut s = Session::new(); open(&mut s, Some(input), false)?; - let fmt = deckcraft_engine::cmd::file::format_for_path(out); let r = if matches!(fmt, "png" | "jpeg") { s.execute("file.export", &json!({"path": out, "slide": 0})) + } else if fmt == "gif" { + s.execute("file.export", &json!({"path": out})) } else { s.execute("file.saveAs", &json!({"path": out})) }; @@ -253,25 +422,50 @@ fn convert(args: &[String]) -> Result<(), String> { Ok(()) } +/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the +/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name). +fn output_format(path: &str) -> Result<&'static str, String> { + use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path}; + writable_format_for_path(path).ok_or_else(|| { + let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into()); + format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", .")) + }) +} + fn run(args: &[String]) -> Result<(), String> { let mut input = None; let mut sample = false; let mut cmds = vec![]; let mut save = None; let mut export = None; + let mut export_params = json!({}); let mut print = false; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?), "--sample" => sample = true, - "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), + "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), + "--export-params" => { + let j = it.next().ok_or("--export-params needs a JSON object")?; + export_params = serde_json::from_str::(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?; + if !export_params.is_object() { + return Err("--export-params must be a JSON object".into()); + } + } "--print" => print = true, + "--help" | "-h" => { + outln!("{}", help_page("run")); + return Ok(()); + } other => return Err(format!("unknown option `{other}`")), } } + for p in save.iter().chain(&export) { + output_format(p)?; + } let mut s = Session::new(); open(&mut s, input.as_deref(), sample)?; for c in cmds { @@ -288,18 +482,76 @@ fn run(args: &[String]) -> Result<(), String> { s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?; } if let Some(p) = export { - s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?; + export_params["path"] = json!(p); + s.execute("file.export", &export_params).map_err(|e| e.to_string())?; + } else if export_params != json!({}) { + return Err("--export-params needs --export OUT".into()); } Ok(()) } fn describe(id: Option<&str>) -> Result<(), String> { + if id.is_some_and(is_help) { + outln!("{}", help_page("describe")); + return Ok(()); + } let id = id.ok_or("describe needs a command id")?; - let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?; - outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params); + let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?; + let mut head = format!("{}: {}", c.id, c.label); + if !c.menu.is_empty() { + head.push_str(&format!(", in {}", c.menu.join(" ▸ "))); + } + if let Some(k) = c.shortcut { + head.push_str(&format!(", shortcut {k}")); + } + outln!("{head}.\n"); + let (args, answer) = c.params.split_once('→').unwrap_or((c.params, "")); + let parsed = deckcraft_engine::cmd::parse_params(c.params); + match &parsed { + Err(_) => outln!("Parameters: {}", args.trim()), + Ok(params) if params.is_empty() => outln!("Takes no parameters."), + Ok(params) => print_params(params), + } + let params = parsed.unwrap_or_default(); + if let Some(acts) = c.acts_on() { + outln!("{acts}"); + } + let remark = remark(args); + if !remark.is_empty() { + outln!("Note: {remark}"); + } + if !answer.trim().is_empty() { + outln!("Answers with {}.", answer.trim()); + } + let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional)); + let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; + outln!("\nRun it with: deckcraft-cli run --cmd {call}"); Ok(()) } +fn print_params(params: &[deckcraft_engine::cmd::Param]) { + let name = |p: &deckcraft_engine::cmd::Param| { + let or = if p.or { "or " } else { "" }; + let optional = if p.optional { ", optional" } else { "" }; + format!("{or}{}{optional}", p.name) + }; + let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default(); + let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0; + outln!("Parameters:"); + for (k, p) in params.iter().enumerate() { + if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) { + outln!(" {}", if p.object == 0 { "either:" } else { "or:" }); + } + let indent = if sets { " " } else { " " }; + outln!("{}", format!("{indent}{: &str { + let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim(); + after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after) +} + fn app(args: &[String]) -> Result<(), String> { use deckcraft_mcp::{Backend, Remote, control_addr}; let mut port = deckcraft_mcp::DEFAULT_PORT.to_string(); @@ -310,6 +562,10 @@ fn app(args: &[String]) -> Result<(), String> { match a.as_str() { "--port" => port = it.next().cloned().ok_or("--port needs a value")?, "--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?), + "--help" | "-h" => { + outln!("{}", help_page("app")); + return Ok(()); + } other => rest.push(other.to_string()), } } @@ -330,24 +586,47 @@ fn app(args: &[String]) -> Result<(), String> { } fn mcp(args: &[String]) -> Result<(), String> { + use deckcraft_engine::file_access::{self, AutomationRoots}; use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option = None; let mut sample = false; - let mut it = args.iter(); + let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut it = args.iter().cloned(); while let Some(a) = it.next() { + if roots_args.consume(&a, &mut it)? { + continue; + } match a.as_str() { - "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), + "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?), "--sample" => sample = true, + "--help" | "-h" => { + outln!("{}", help_page("mcp")); + return Ok(()); + } other => return Err(format!("unknown mcp option `{other}`")), } } + let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ +so drop --connect (or use --connect without the roots)" + .into()); + } let backend: Box = match connect { Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?), None => { let mut h = Headless::new(); + // `--sample` is the operator's own choice, made before the roots come into force. if sample { deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?; } + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + h = h.with_automation_roots(Some(roots)); + } Box::new(h) } }; @@ -355,3 +634,119 @@ fn mcp(args: &[String]) -> Result<(), String> { let stdin = std::io::stdin(); server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn args(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn render_help_prints_usage_and_writes_nothing() { + assert!(render(&args(&["--help"])).is_ok()); + assert!(render(&args(&["-h"])).is_ok()); + assert!(!std::path::Path::new("--help").exists()); + } + + #[test] + fn render_rejects_unknown_flag() { + let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err(); + assert_eq!(err, "unknown option `--scael`"); + assert!(!std::path::Path::new("--scael").exists()); + } + + #[test] + fn other_subcommands_accept_help() { + assert!(info(&args(&["--help"])).is_ok()); + assert!(convert(&args(&["-h"])).is_ok()); + assert!(run(&args(&["--help"])).is_ok()); + assert!(describe(Some("--help")).is_ok()); + assert!(mcp(&args(&["-h"])).is_ok()); + } + + #[test] + fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() { + let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png"); + let _ = std::fs::remove_file(&out); + for flag in ["--bogus", "-x"] { + let args: Vec = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect(); + let e = render(&args).unwrap_err(); + assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}"); + assert!(!out.exists(), "{flag}: rendered anyway"); + } + } + + /// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs. + #[test] + fn exports_an_animated_gif() { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gif = dir.join("sample.gif").to_string_lossy().to_string(); + let deck = dir.join("sample.deckcraft").to_string_lossy().to_string(); + let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#; + run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap(); + let bytes = std::fs::read(&gif).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block"); + + let gif2 = dir.join("converted.gif").to_string_lossy().to_string(); + convert(&args(&[&deck, &gif2])).unwrap(); + let bytes = std::fs::read(&gif2).unwrap(); + assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default"); + + // Bad options are errors, not panics. + let bad = dir.join("bad.gif").to_string_lossy().to_string(); + assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err()); + assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err()); + assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok()); + assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod slide_index_tests { + use super::{missing_slide, slide_indices}; + + #[test] + fn slide_numbers_start_at_zero_and_files_at_one() { + assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..])); + assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..])); + assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..])); + assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..])); + let err = slide_indices(8, false, Some(8)).err().unwrap_or_default(); + assert!(!err.is_empty(), "index 8 was accepted"); + assert!(err.contains("numbered 0 through 7"), "{err}"); + assert!(err.contains("slide-01.png"), "{err}"); + assert!(err.contains("slide-08.png"), "{err}"); + assert!(missing_slide(0, 0).contains("no slides")); + assert!(missing_slide(1, 1).contains("index 0")); + } +} + +#[cfg(test)] +mod frame_path_tests { + use super::frame_paths; + + #[test] + fn single_frame_uses_out_as_is() { + assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()])); + } + + #[test] + fn frames_get_distinct_names() { + let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default(); + assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]); + } + + #[test] + fn values_rounding_to_one_name_are_rejected() { + assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err()); + assert!(frame_paths("f.png", &[0.5, 0.5]).is_err()); + } +} diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs new file mode 100644 index 00000000..d04e55c7 --- /dev/null +++ b/apps/deckcraft-cli/tests/cli.rs @@ -0,0 +1,139 @@ +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn stdout(o: &Output) -> String { + assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr)); + String::from_utf8_lossy(&o.stdout).into_owned() +} + +fn stderr(o: &Output) -> String { + assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout)); + String::from_utf8_lossy(&o.stderr).into_owned() +} + +const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"]; + +fn words(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +#[test] +fn an_unknown_subcommand_is_named_before_the_overview() { + for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { + let err = stderr(&cli(&args)); + assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}"); + } +} + +#[test] +fn no_subcommand_shows_the_overview_and_fails() { + assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on")); +} + +#[test] +fn help_lists_every_subcommand_with_a_run_example() { + for flag in ["help", "--help", "-h"] { + let out = stdout(&cli(&[flag])); + for sub in SUBCOMMANDS { + assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}"); + } + assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}"); + } +} + +#[test] +fn every_subcommand_answers_help_and_does_nothing_else() { + let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for sub in SUBCOMMANDS { + for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] { + let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}"); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing"); + std::fs::remove_dir_all(&dir).unwrap(); + for args in [["help", "version"], ["--version", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}"); + } + for args in [["help", "help"], ["help", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}"); + } +} + +#[test] +fn run_help_says_what_the_ids_do_not() { + let out = words(&stdout(&cli(&["run", "--help"]))); + for fact in [ + "COMMAND is a command id, then `=`, then the command's parameters as one JSON object.", + "points on a 960×540 slide", + "Slide indices start at 0.", + "Shape ids are looked up on the current slide only.", + "A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.", + "format.* acts on the selected text or shapes", + ] { + assert!(out.contains(fact), "{fact}: {out}"); + } +} + +#[test] +fn describe_spells_out_a_command() { + assert_eq!( + stdout(&cli(&["describe", "shape.insert"])), + "shape.insert: Shapes, in Insert ▸ Illustrations. + +Parameters: + preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets) + rect [x,y,w,h] pt + text, optional string + +Run it with: deckcraft-cli run --cmd 'shape.insert={…}' +" + ); + assert_eq!( + stdout(&cli(&["describe", "format.size"])), + "format.size: Font Size, in Home ▸ Font. + +Parameters: + size pt +Acts on the selected text or shapes. + +Run it with: deckcraft-cli run --cmd 'format.size={…}' +" + ); + assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); + assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); + assert_eq!( + stdout(&cli(&["describe", "shape.setDefault"])), + "shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n" + ); +} + +#[test] +fn describe_shows_alternative_sets_of_parameters() { + assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n")); + let remove = stdout(&cli(&["describe", "animation.remove"])); + assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}"); + assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}"); + assert!(stdout(&cli(&["describe", "shape.move"])).contains( + " either: + dx, optional pt (relative) + dy, optional pt (relative) + ids, optional + or: + x, optional pt (absolute) + y, optional pt (absolute) + ids, optional +" + )); +} + +#[test] +fn commands_lists_one_line_per_command() { + assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n"); + assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n"); + assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n"); +} diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs new file mode 100644 index 00000000..4f162994 --- /dev/null +++ b/apps/deckcraft-cli/tests/convert.rs @@ -0,0 +1,57 @@ +//! `deckcraft-cli convert` writes the format OUT's extension names, or fails without writing. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn cli(args: &[&Path]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn convert_rejects_output_formats_it_cannot_write() { + let dir = temp_dir("convert-unsupported"); + let input = dir.join("in.pptx"); + let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] { + let target = dir.join(name); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(!out.status.success(), "{name}: convert should fail"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("unsupported output format") && err.contains(".pptx"), "{name}: {err}"); + assert!(!target.exists(), "{name}: nothing is written"); + } + + let target = dir.join("out.deckcraft"); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::read(&target).unwrap().starts_with(b"PK\x03\x04")); + + let target = dir.join("out.txt"); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!std::fs::read(&target).unwrap().starts_with(b"PK"), "an outline is text, not a zip"); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn run_rejects_unsupported_save_and_export_paths_before_running() { + let dir = temp_dir("run-unsupported"); + for flag in ["--save", "--export"] { + let target = dir.join("deck.odp"); + let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", flag]).arg(&target).output().unwrap(); + assert!(!out.status.success(), "{flag}: run should fail"); + assert!(String::from_utf8_lossy(&out.stderr).contains("unsupported output format .odp"), "{flag}"); + assert!(!target.exists(), "{flag}: nothing is written"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/deckcraft-cli/tests/mcp_roots.rs b/apps/deckcraft-cli/tests/mcp_roots.rs new file mode 100644 index 00000000..e9e1f856 --- /dev/null +++ b/apps/deckcraft-cli/tests/mcp_roots.rs @@ -0,0 +1,118 @@ +//! `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; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")) +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().unwrap() +} + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp `; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { + let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = tmpdir("roots"); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(work.join("in.txt"), "Inside text").unwrap(); + std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap(); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})), + ("open_presentation", serde_json::json!({"path": secret})), + ("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})), + ("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.deckcraft").exists()); + assert_eq!(replies[3]["result"]["isError"], true); + assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3])); + assert!(!elsewhere.join("saved.deckcraft").exists()); + + // A root left out grants none of its access. + let save = [("new_presentation", serde_json::json!({})), calls[2].clone()]; + let (_, _, replies) = mcp_session(&[&read_flag], &save); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = tmpdir("roots-args"); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = run(&full); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--sample"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 366ea346..6d2b7e94 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -7,7 +7,8 @@ //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); //! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive -//! asynchronously through `Services::inbox` (presentations open, media is inserted); +//! asynchronously through `Services::inbox`, applied to the document, slide and operation +//! that started the pick (a dismissed picker or a closed document drops the file); //! - Save and Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - preferences are not persisted. diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a55..dabad8af 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. use deckcraft_engine::Session; -use deckcraft_ui_egui::{Inbox, Services, SlideApp}; +use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; use wasm_bindgen::JsCast as _; const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"]; @@ -77,16 +77,23 @@ impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { + // Capture the document and slide now. The bytes arrive later, after the user may + // have switched slides. + let id = self.app.begin_external_file("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes })); + ctx.request_repaint(); + } + Err(e) => { + log::error!("couldn't read dropped file {name}: {e}"); + push_inbox(&inbox, InboxEvent::Cancelled { id }); ctx.request_repaint(); } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -107,7 +114,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - open_async: Some(Box::new(move |purpose: &str| { + open_async: Some(Box::new(move |purpose: &str, request: u64| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); let dialog = match purpose { @@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { }; wasm_bindgen_futures::spawn_local(async move { let Some(file) = dialog.pick_file().await else { + push_inbox(&inbox, InboxEvent::Cancelled { id: request }); + ctx.request_repaint(); return; }; let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes })); ctx.request_repaint(); }); })), @@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +fn push_inbox(inbox: &Inbox, event: InboxEvent) { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event); +} + /// Trigger a browser download of `bytes` named after the last component of `path`. fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); @@ -168,6 +181,7 @@ fn mime_for(name: &str) -> &'static str { Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("pdf") => "application/pdf", + Some("gif") => "image/gif", Some("txt") => "text/plain", Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation", Some("deckcraft") => "application/zip", diff --git a/apps/deckcraft/src/audio.rs b/apps/deckcraft/src/audio.rs index 53aa48cc..afa74a6a 100644 --- a/apps/deckcraft/src/audio.rs +++ b/apps/deckcraft/src/audio.rs @@ -1,6 +1,7 @@ //! cpal audio output for media playback: the device pulls mixed samples from the //! `deckcraft_media::Player`, which makes it the playback clock video frames follow. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option { pub struct CpalOut { stream: Option, error: ErrorSlot, + /// Set by the realtime error callback when the device went away: the stream is dead. + dead: Arc, } impl CpalOut { @@ -56,7 +59,14 @@ impl AudioOut for CpalOut { let rate = cfg.sample_rate().0; let config: cpal::StreamConfig = cfg.clone().into(); let slot = self.error.clone(); - let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e); + let dead = Arc::new(AtomicBool::new(false)); + self.dead = dead.clone(); + let err = move |e: cpal::StreamError| { + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + dead.store(true, Ordering::Relaxed); + } + keep_stream_error(&slot, &e); + }; let stream = match cfg.sample_format() { cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None), cpal::SampleFormat::I16 => { @@ -86,6 +96,13 @@ impl AudioOut for CpalOut { self.stream = None; self.report(); } + + fn failure(&mut self) -> Option { + if !self.dead.swap(false, Ordering::Relaxed) { + return None; + } + Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string())) + } } #[cfg(test)] diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs index f50de025..966657b0 100644 --- a/apps/deckcraft/src/control_server.rs +++ b/apps/deckcraft/src/control_server.rs @@ -1,39 +1,147 @@ //! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps. +//! +//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request +//! line length, queued requests, and how long a socket may sit idle or blocked on a write. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::time::Duration; use deckcraft_ui_egui::ControlRequest; use serde_json::{Value, json}; +/// Resource bounds for the control server. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + /// Connections served at once; more are told `busy` and closed. + pub max_clients: usize, + /// Longest request line in bytes (newline excluded); a longer one closes the connection. + pub max_line: usize, + /// Requests waiting for the UI at once; more are answered `busy`. + pub queue: usize, + /// How long a connection may go without sending a byte before it is closed. + pub idle_timeout: Duration, + /// How long a reply write may block before the connection is closed. + pub write_timeout: Duration, + /// How long a request waits for the UI's answer. + pub reply_timeout: Duration, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_clients: 16, + // Picture and media inserts carry base64 payloads: room for files of about 190 MB. + max_line: 256 * 1024 * 1024, + queue: 64, + idle_timeout: Duration::from_secs(600), + write_timeout: Duration::from_secs(30), + reply_timeout: Duration::from_secs(60), + } + } +} + pub fn start(port: u16, ctx: egui::Context) -> Receiver { - let (tx, rx) = channel::(); - let listener = match TcpListener::bind(("127.0.0.1", port)) { - Ok(l) => l, + match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => { + log::info!("control server listening on 127.0.0.1:{port}"); + serve_listener(l, Limits::default(), ctx) + } Err(e) => { log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); - return rx; + sync_channel::(0).1 } - }; - log::info!("control server listening on 127.0.0.1:{port}"); + } +} + +/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver. +fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver { + let (tx, rx) = sync_channel::(limits.queue.max(1)); + let active = Arc::new(AtomicUsize::new(0)); std::thread::spawn(move || { - for stream in listener.incoming().flatten() { + for mut stream in listener.incoming().flatten() { + let _ = stream.set_write_timeout(Some(limits.write_timeout)); + if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients { + active.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"})); + continue; + } + let slot = Slot(Arc::clone(&active)); let tx = tx.clone(); let ctx = ctx.clone(); - std::thread::spawn(move || serve(stream, tx, ctx)); + let spawned = std::thread::Builder::new().name("control-client".into()).spawn(move || { + let _slot = slot; + serve(stream, &tx, &ctx, limits); + }); + if spawned.is_err() { + log::warn!("control server could not start a client thread"); + } } }); rx } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { +/// Holds one connection slot; gives it back when the client thread ends, however it ends. +struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +enum Line { + Text(Vec), + TooLong, + Closed, +} + +/// Read one `\n`-terminated line of at most `max` bytes without buffering more than `max + 1`. +fn read_line(r: &mut impl BufRead, max: usize) -> Line { + let mut buf = Vec::new(); + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + match r.by_ref().take(limit).read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => Line::Closed, + Ok(_) => { + if buf.last() == Some(&b'\n') { + buf.pop(); + Line::Text(buf) + } else if buf.len() > max { + Line::TooLong + } else { + // EOF inside a line: serve what arrived, the next read reports Closed. + Line::Text(buf) + } + } + } +} + +fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits) { + if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { + return; + } let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - for line in BufReader::new(read).lines() { - let Ok(line) = line else { break }; + let mut reader = BufReader::new(read); + loop { + let line = match read_line(&mut reader, limits.max_line) { + Line::Text(t) => t, + Line::TooLong => { + let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)})); + break; + } + Line::Closed => break, + }; + let Ok(line) = String::from_utf8(line) else { + if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() { + break; + } + continue; + }; if line.trim().is_empty() { continue; } @@ -43,11 +151,14 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); let (req, rrx) = ControlRequest::new(method, params); - if tx.send(req).is_err() { - break; - } - ctx.request_repaint(); - let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + let mut r = match tx.try_send(req) { + Ok(()) => { + ctx.request_repaint(); + rrx.recv_timeout(limits.reply_timeout).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})) + } + Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}), + Err(TrySendError::Disconnected(_)) => break, + }; if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } @@ -55,8 +166,134 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { } Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), }; - if writeln!(out, "{reply}").is_err() { + if let Err(e) = writeln!(out, "{reply}") { + if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) { + log::debug!("control client stopped reading replies; closing"); + } break; } } } + +#[cfg(test)] +mod tests { + use super::*; + 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) { + 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) { + 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) -> Option { + 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) { + 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"); + } + + #[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); + } +} diff --git a/apps/deckcraft/src/display.rs b/apps/deckcraft/src/display.rs new file mode 100644 index 00000000..2a6eeca2 --- /dev/null +++ b/apps/deckcraft/src/display.rs @@ -0,0 +1,85 @@ +//! Which windowing system to start on, on Linux and the BSDs. +//! +//! DeckCraft prefers X11 when `DISPLAY` is set (see `main`). A stale `DISPLAY` (left over from +//! an ended `ssh -X` session or a desktop with Xwayland turned off) used to make start-up fail +//! with winit's "failed to open display" even though a Wayland compositor was running. When a +//! local `DISPLAY` names an X server whose socket isn't there and Wayland is available, start on +//! Wayland instead. With no display at all, say so plainly. + +use std::path::Path; + +/// The windowing system to ask winit for. +#[derive(Debug, PartialEq, Eq)] +pub enum Choice { + X11, + /// Let winit decide (Wayland when `WAYLAND_DISPLAY` is set). + Default, + /// Neither `DISPLAY` nor `WAYLAND_DISPLAY`: there is nothing to open a window on. + None, +} + +/// The X server number of a local `DISPLAY` (`:1`, `:1.0`, `unix:1`); `None` for a remote one +/// (`host:10.0`, as `ssh -X` sets) or one we can't read, which are left to X11 itself. +fn local_display_number(display: &str) -> Option { + let rest = display.strip_prefix(':').or_else(|| display.strip_prefix("unix:"))?; + rest.split('.').next()?.parse().ok() +} + +/// Choose from the environment. `socket_exists` checks an X socket path (the filesystem in +/// [`choose_from_env`], a stub in tests). +pub fn choose(display: Option<&str>, wayland: Option<&str>, force_wayland: bool, socket_exists: impl Fn(&Path) -> bool) -> Choice { + let display = display.filter(|d| !d.is_empty()); + let wayland = wayland.is_some_and(|w| !w.is_empty()); + match display { + None if wayland => Choice::Default, + None => Choice::None, + Some(_) if force_wayland && wayland => Choice::Default, + Some(d) => match local_display_number(d) { + Some(n) if wayland && !socket_exists(&Path::new("/tmp/.X11-unix").join(format!("X{n}"))) => { + log::warn!("DISPLAY={d} has no X server; starting on Wayland"); + Choice::Default + } + _ => Choice::X11, + }, + } +} + +/// [`choose`] for this process. +pub fn choose_from_env() -> Choice { + let display = std::env::var("DISPLAY").ok(); + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_WAYLAND").is_some(), |p| p.exists()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_stale_local_display_falls_back_to_wayland() { + let none = |_: &Path| false; + let all = |_: &Path| true; + assert_eq!(choose(Some(":1"), Some("wayland-0"), false, none), Choice::Default); + assert_eq!(choose(Some(":1.0"), Some("wayland-0"), false, none), Choice::Default); + assert_eq!(choose(Some("unix:0"), Some("wayland-0"), false, none), Choice::Default); + // A live X server, no Wayland, or a remote DISPLAY: X11 as before. + assert_eq!(choose(Some(":1"), Some("wayland-0"), false, all), Choice::X11); + assert_eq!(choose(Some(":1"), None, false, none), Choice::X11); + assert_eq!(choose(Some("host:10.0"), Some("wayland-0"), false, none), Choice::X11); + // The socket checked is the display's own. + assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X3")), Choice::X11); + assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X0")), Choice::Default); + } + + #[test] + fn wayland_only_forced_and_no_display_at_all() { + let all = |_: &Path| true; + assert_eq!(choose(None, Some("wayland-0"), false, all), Choice::Default); + assert_eq!(choose(Some(""), Some("wayland-0"), false, all), Choice::Default); + assert_eq!(choose(Some(":0"), Some("wayland-0"), true, all), Choice::Default); + // Forcing Wayland without a compositor keeps X11 rather than failing. + assert_eq!(choose(Some(":0"), None, true, all), Choice::X11); + assert_eq!(choose(None, None, false, all), Choice::None); + assert_eq!(choose(Some(""), Some(""), false, all), Choice::None); + } +} diff --git a/apps/deckcraft/src/graphics.rs b/apps/deckcraft/src/graphics.rs index ccd0c459..fc054c06 100644 --- a/apps/deckcraft/src/graphics.rs +++ b/apps/deckcraft/src/graphics.rs @@ -1,10 +1,14 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, Dx12Compiler}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { +/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit +/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence. +pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { // eframe's default includes GL. Creating that backend can crash inside AMD's // atio6axx.dll before adapter selection or any Rust error handling runs, so the @@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 // workaround without mutating process environment variables. create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + // Auto loads whichever dxcompiler.dll the DLL search path finds; DeckCraft ships + // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's + // indirect-validation pipeline and loses the device before the first frame + // (issue #153). FXC uses the compiler that is part of Windows. + create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc); + log::info!( + "Windows graphics: backends={:?}, DX12 compiler={:?}", + create.instance_descriptor.backends, + create.instance_descriptor.backend_options.dx12.shader_compiler + ); } } @@ -21,7 +35,7 @@ mod tests { fn configured_backends(backend_override: Option) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure(&mut options, backend_override, None); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; @@ -38,6 +52,38 @@ mod tests { assert_eq!(configured_backends(None), Backends::DX12); } + #[test] + fn windows_default_uses_fxc_instead_of_searching_for_dxc() { + let mut options = NativeOptions::default(); + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // Auto can find an unrelated DXC without its matching DXIL runtime (issue #153). + create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto; + } + configure(&mut options, None, None); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + + #[test] + fn explicit_compiler_override_is_preserved() { + for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] { + let mut options = NativeOptions::default(); + configure(&mut options, None, Some(compiler.clone())); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) { + (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {} + (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => { + assert_eq!(actual, expected); + } + (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"), + } + } + } + #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..ef80daaa 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -1,6 +1,7 @@ //! DeckCraft desktop app. //! //! Usage: `deckcraft [--control ] [--sample] [--show] [files…]` +//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits. //! //! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. @@ -14,9 +15,13 @@ mod audio; mod control_server; +#[cfg(all(unix, not(target_os = "macos")))] +mod display; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; +#[cfg(not(target_arch = "wasm32"))] +mod pasteboard; use deckcraft_engine::Session; use deckcraft_ui_egui::{Services, SlideApp}; @@ -35,6 +40,16 @@ impl eframe::App for App { } fn on_exit(&mut self) { save_prefs(&self.0); + // macOS (#279): end the process here, before eframe tears the window down. AccessKit's + // macOS adapter subclasses the window's content view and restores its class on teardown; + // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle + // `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends + // in a crash report. eframe calls `on_exit` on every way of quitting (⌘Q, the close + // button, `app.quit`), once the unsaved-changes prompt has let the window close, and + // nothing after it is needed: the preferences are saved and the log file is unbuffered. + // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117. + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -52,24 +67,16 @@ fn config_dir() -> Option { } } -fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) -} - fn load_prefs(app: &mut SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + let Some(dir) = config_dir() else { return }; + let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir); + if let Some(ui) = ui { app.ui = ui; } - if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(prefs) = serde_json::from_slice::(&bytes) - { + if let Some(prefs) = prefs { app.session.prefs = prefs; } } @@ -78,14 +85,8 @@ fn save_prefs(app: &SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { - let _ = std::fs::write(&p, bytes); - } - if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { - let _ = std::fs::write(p.with_file_name("prefs.json"), bytes); - } + if let Some(dir) = config_dir() { + deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs); } } @@ -100,6 +101,7 @@ fn services() -> Services { &["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"], ), "video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]), + "theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]), _ => d .add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"]) .add_filter("DeckCraft", &["deckcraft"]) @@ -111,14 +113,9 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - clipboard_image: Some(Box::new(|| { - let mut cb = arboard::Clipboard::new().ok()?; - let img = cb.get_image().ok()?; - let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?; - let mut out = Vec::new(); - rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) - })), + clipboard_image: Some(Box::new(pasteboard::image_png)), + clipboard_html: Some(Box::new(pasteboard::html)), + clipboard_set_html: Some(Box::new(pasteboard::set_html)), // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently. audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO") .is_none() @@ -129,6 +126,41 @@ fn services() -> Services { const APP_ID: &str = "ai.storyteller.deckcraft"; +const USAGE: &str = "\ +Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...] + + --control PORT JSON control server on 127.0.0.1:PORT + --sample open the sample presentation + --show start the slide show + --help, -h print this help and exit + --version, -V print the version and exit +"; + +enum Startup { + Help, + Version, + Run { control_port: Option, files: Vec, sample: bool, show: bool }, +} + +/// `--help` and `--version` leave before the window and the log file. +fn startup(mut control_port: Option, args: impl IntoIterator) -> Startup { + let mut files = Vec::new(); + let mut sample = false; + let mut show = false; + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--show" => show = true, + "--version" | "-V" => return Startup::Version, + "--help" | "-h" => return Startup::Help, + _ => files.push(a), + } + } + Startup::Run { control_port, files, sample, show } +} + fn app_icon() -> Option { let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"); eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() @@ -138,26 +170,21 @@ fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); - let mut control_port: Option = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); - let mut files = Vec::new(); - let mut sample = false; - let mut show = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), - "--sample" => sample = true, - "--show" => show = true, - "--version" => { + let (control_port, files, sample, show) = + match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) { + Startup::Help => { + print!("{USAGE}"); + return Ok(()); + } + Startup::Version => { println!("deckcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - _ => files.push(a), - } - } + Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show), + }; log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); - // The log file lives under the settings directory; opened after the arguments, so `--version` - // leaves no file behind. Records logged until now are written to it first. + // The log file lives under the settings directory; opened after the arguments, so `--help` and + // `--version` leave no file behind. Records logged until now are written to it first. if let Some(logger) = logger { match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { Some(Ok(path)) => log::info!("log file {}", path.display()), @@ -184,15 +211,26 @@ fn main() -> eframe::Result { options.viewport = options.viewport.with_icon(icon); } #[cfg(all(unix, not(target_os = "macos")))] - if std::env::var_os("DISPLAY").is_some() && std::env::var_os("DECKCRAFT_WAYLAND").is_none() { + let x11 = match display::choose_from_env() { + display::Choice::X11 => true, + display::Choice::Default => false, + display::Choice::None => { + // winit's own error says little to someone who started DeckCraft from a console. + log::error!("no display: DISPLAY and WAYLAND_DISPLAY are not set"); + eprintln!("DeckCraft needs a graphical session: neither DISPLAY nor WAYLAND_DISPLAY is set."); + false + } + }; + #[cfg(all(unix, not(target_os = "macos")))] + if x11 { options.event_loop_builder = Some(Box::new(|b| { use winit::platform::x11::EventLoopBuilderExtX11; b.with_x11(); })); } - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); eframe::run_native( "DeckCraft", options, @@ -228,3 +266,47 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// #279: on macOS the process ends inside `on_exit`, before eframe would tear the window down. + /// The check runs in a child (this test binary again), since on macOS it ends the process. + #[test] + fn on_exit_ends_the_process_on_macos() { + if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() { + let mut app = App(SlideApp::new(Session::new(), Services::default())); + println!("calling on_exit"); + eframe::App::on_exit(&mut app); + println!("on_exit returned"); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) + .env("DECKCRAFT_TEST_ON_EXIT", "1") + .env("DECKCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{stdout}"); + assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); + assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); + } + + #[test] + fn help_exits_instead_of_opening_a_file() { + assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version)); + let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]); + assert!(matches!(launch, Startup::Run { .. })); + if let Startup::Run { control_port, files, sample, show } = launch { + assert_eq!(control_port, Some(4242)); + assert_eq!(files, ["talk.pptx".to_string()]); + assert!(sample); + assert!(!show); + } + } +} diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs new file mode 100644 index 00000000..98626295 --- /dev/null +++ b/apps/deckcraft/src/pasteboard.rs @@ -0,0 +1,77 @@ +//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard). +//! +//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain +//! words. A shape with no text is copied as an image by the UI; this module only reads a picture +//! back (Keynote's TIFF) as PNG bytes. + +/// HTML currently on the pasteboard, when there is some. +pub fn html() -> Option { + let mut cb = arboard::Clipboard::new().ok()?; + let html = cb.get().html().ok()?; + let html = html.trim(); + if html.is_empty() { None } else { Some(html.to_string()) } +} + +/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it. +pub fn set_html(html: &str, text: &str) -> bool { + let Ok(mut cb) = arboard::Clipboard::new() else { return false }; + cb.set_html(html, Some(text)).is_ok() +} + +/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard). +pub fn image_png() -> Option> { + let mut cb = arboard::Clipboard::new().ok()?; + let img = cb.get_image().ok()?; + let width = u32::try_from(img.width).ok()?; + let height = u32::try_from(img.height).ok()?; + let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?; + let mut out = Vec::new(); + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back. + /// Restores whatever plain text was there. Other platforms return immediately. + #[test] + fn html_and_a_picture_round_trip_on_macos() { + if !cfg!(target_os = "macos") { + return; + } + let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()); + let _restore = Restore(previous); + assert!(super::set_html("DeckCraft", "DeckCraft"), "set html"); + let html = super::html().unwrap_or_default(); + assert!(html.contains("") || html.contains(""), "{html}"); + let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default(); + assert_eq!(text, "DeckCraft"); + let mut cb = arboard::Clipboard::new().expect("pasteboard"); + let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) }; + cb.set_image(img).expect("set image"); + drop(cb); + let png = super::image_png().expect("png"); + let decoded = image::load_from_memory(&png).expect("decode").to_rgba8(); + let p = decoded.get_pixel(0, 0); + assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}"); + } + + /// Puts the previous plain text back, including when the test fails halfway. + struct Restore(Option); + + impl Drop for Restore { + fn drop(&mut self) { + let Ok(mut cb) = arboard::Clipboard::new() else { return }; + match self.0.take() { + Some(t) => { + let _ = cb.set_text(t); + } + None => { + let _ = cb.clear(); + } + } + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index c10fb209..f70303a1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -41,3 +41,16 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + +[people.Momentum96] +sync_github_name = true + +[people.NikitkaRa] +real_name = "Nikita Rakitin" + +[people.RCShoemaker] +real_name = "Ryan C. Shoemaker" diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs index 37776f83..e51052d9 100644 --- a/crates/anim/src/effects.rs +++ b/crates/anim/src/effects.rs @@ -44,6 +44,15 @@ pub(crate) fn exit_base(effect: &str) -> &str { "swivelOut" => "swivel", "bounceOut" => "bounce", "dissolveOut" => "dissolve", + "blindsOut" => "blinds", + "boxOut" => "box", + "checkerboardOut" => "checkerboard", + "diamondOut" => "diamond", + "peekOut" => "peek", + "stripsOut" => "strips", + "sinkDown" => "rise", + "spinnerOut" => "spinner", + "contract" => "expand", other => other, } } @@ -69,6 +78,7 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool { fn uses_default_smoothing(effect: &str) -> bool { matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath") + || deckcraft_model::anim::preset_path(effect).is_some() } impl EffectSpec { diff --git a/crates/anim/src/interact.rs b/crates/anim/src/interact.rs new file mode 100644 index 00000000..e32d980e --- /dev/null +++ b/crates/anim/src/interact.rs @@ -0,0 +1,266 @@ +//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a +//! link, the next step) and what the show remembers about each slide it has visited. +//! +//! The rules follow PowerPoint, as observed on its slide show: +//! - the topmost visible shape under the pointer decides; a shape without an action on top of one +//! with an action blocks it (the click then counts as a click on the background); +//! - a shape with a triggered animation plays the next step of it per click, and starts over once all +//! its steps have played; clicking it while a step plays completes the step at once; +//! - a shape with a link and a triggered animation follows the link at once. The animation's clock +//! runs only while its slide is shown, so it goes on where it stopped when the show returns; +//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps; +//! - a slide the show returns to (link or arrow keys) looks as it was left. + +use deckcraft_model::text::Action; +use deckcraft_model::{Presentation, ShapeId}; + +use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay}; + +/// What was played on one slide. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct SlideRun { + /// The show has been on this slide. + pub visited: bool, + /// Main-sequence steps played when the show left the slide. + pub step: usize, + /// Triggered sequences started by clicks. + pub triggers: Vec, + /// Steps of triggered sequences started so far (the stamps of [`StepStamp`]). + pub clock: u64, +} + +/// Progress of one triggered sequence. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TriggerRun { + pub shape: ShapeId, + /// Clicks whose step has played to its end. + pub done: usize, + /// Seconds the current step has played; `None` when no step is playing. + pub elapsed: Option, + /// When each step of this run was started, by step. + pub stamps: Vec, +} + +/// One shape of the slide for hit testing, in drawing order (back to front; a group before its +/// children), with whether the pointer is inside its box. +#[derive(Clone, Debug, PartialEq)] +pub struct HitShape { + pub id: ShapeId, + /// Index of the enclosing group in the same list. + pub parent: Option, + pub is_group: bool, + /// The shape's own action. + pub link: Option, + pub under_pointer: bool, +} + +/// What the pointer is over: a shape that does something when clicked. +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + pub shape: ShapeId, + pub link: Option, +} + +/// What a click did. +#[derive(Clone, Debug, PartialEq)] +pub enum Click { + /// Nothing happened. + None, + /// A triggered animation started or was completed: redraw. + Animated, + /// Navigation inside the show (already applied to the state). + Show(ShowAction), + /// Open this URL outside the show. + Open(String), + /// End the show. + Exit, +} + +impl From for Click { + fn from(j: LinkJump) -> Click { + match j { + LinkJump::Show(a) => Click::Show(a), + LinkJump::Open(u) => Click::Open(u), + LinkJump::Exit => Click::Exit, + LinkJump::None => Click::None, + } + } +} + +fn finite_or_zero(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +impl ShowState { + fn animated(&self, pres: &Presentation) -> bool { + !pres.show.without_animation + } + + /// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has + /// played to its end. + fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool { + self.animated(pres) && tl.trigger_steps(shape) > 0 + } + + /// The playheads of the current slide's triggered sequences at the last tick, for + /// [`Timeline::state_with`]. + pub fn trigger_plays(&self) -> Vec { + match self.runs.get(&self.slide) { + Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(), + None => vec![], + } + } + + /// When the steps of the current slide's triggered sequences were started. + pub fn trigger_stamps(&self) -> Vec { + let Some(r) = self.runs.get(&self.slide) else { return vec![] }; + r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect() + } + + /// Is a triggered animation of the current slide playing (so the picture changes every frame)? + pub fn trigger_playing(&self) -> bool { + self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some())) + } + + /// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds + /// played, plus the triggered sequences. + pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState { + tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps()) + } + + /// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock) + /// and complete the steps that reached their end. [`Click::Animated`] when the picture changed. + pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click { + let dt = match self.last_now { + Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0), + _ => 0.0, + }; + if now.is_finite() { + self.last_now = Some(now); + } + let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None }; + let mut changed = false; + for t in run.triggers.iter_mut() { + let Some(e) = t.elapsed else { continue }; + let e = finite_or_zero(e) + dt; + if e >= tl.trigger_step_duration(t.shape, t.done) { + t.done += 1; + t.elapsed = None; + changed = true; + } else { + t.elapsed = Some(e); + } + } + if changed { Click::Animated } else { Click::None } + } + + /// Index of the topmost shape a click lands on: the front-most one under the pointer that is + /// shown. A group without an action does not count; its children and the space inside its box + /// are not it. + pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option { + let shown = |mut i: usize| -> bool { + // A shape is shown when it and the groups around it are. + for _ in 0..=shapes.len() { + let Some(h) = shapes.get(i) else { return false }; + if !visible(h.id) { + return false; + } + match h.parent { + Some(p) => i = p, + None => return true, + } + } + false + }; + (0..shapes.len()) + .rev() + .find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i))) + } + + fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool { + h.link.is_some() || self.trigger_live(pres, tl, h.id) + } + + /// The shape under the pointer that a click would act on, `None` when a click would be a click + /// on the background. `visible` tells whether a shape is currently shown (not yet entered or + /// already exited shapes cannot be clicked). + pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option { + // The topmost shape, or the group it is in, that has an action. A shape without one blocks + // what lies beneath it. + let mut at = self.top(pres, tl, shapes, visible); + for _ in 0..=shapes.len() { + let h = shapes.get(at?)?; + if self.interactive(pres, tl, h) { + return Some(Target { shape: h.id, link: h.link.clone() }); + } + at = h.parent; + } + None + } + + /// A mouse click. `shapes` and `visible` describe what is under the pointer (see + /// [`ShowState::target`]); `last_viewed` is the slide shown before this one. + pub fn click( + &mut self, + pres: &Presentation, + tl: &Timeline, + shapes: &[HitShape], + visible: &dyn Fn(ShapeId) -> bool, + now: f64, + last_viewed: Option, + ) -> Click { + self.tick(tl, now); + if self.ended { + return Click::None; + } + let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) }; + let mut animated = false; + if self.trigger_live(pres, tl, t.shape) { + let slide = self.slide; + let run = self.runs.entry(slide).or_default(); + let at = match run.triggers.iter().position(|r| r.shape == t.shape) { + Some(p) => p, + None => { + run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] }); + run.triggers.len() - 1 + } + }; + let stamp = run.clock.saturating_add(1); + if let Some(r) = run.triggers.get_mut(at) { + if r.elapsed.is_some() { + // A click on a shape whose step is playing completes the step. + r.done += 1; + r.elapsed = None; + } else { + if r.done >= tl.trigger_steps(t.shape) { + // All steps have played: the sequence starts over. + r.done = 0; + r.stamps.clear(); + } + r.stamps.push(stamp); + run.clock = stamp; + if tl.trigger_step_duration(t.shape, r.done) > 0.0 { + r.elapsed = Some(0.0); + } else { + r.done += 1; + } + } + animated = true; + } + } + match t.link { + // The link is followed at once, whatever the animation still has to play. + Some(a) => self.follow(pres, &a, last_viewed).into(), + None if animated => Click::Animated, + None => Click::None, + } + } + + /// A click that is on no shape with an action: next animation step, then next slide, unless the + /// slide has "advance on click" off, which leaves only the animation steps. + fn background_click(&mut self, pres: &Presentation) -> Click { + let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click); + let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide); + if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None } + } +} diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index d94167ca..cf8fb3ff 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -5,7 +5,8 @@ //! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images. //! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by //! [`morph_xfrm`]). -//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings). +//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was +//! played on each visited slide, and what a click does ([`ShowState::click`]). //! //! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or //! negative times) and never panics. @@ -14,6 +15,7 @@ mod easing; mod effects; +mod interact; mod mesh; mod morph; mod path; @@ -26,17 +28,20 @@ mod transition_geom; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_interact; +#[cfg(test)] mod transition_tests; pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; +pub use interact::{Click, HitShape, Target}; pub use mesh::{MeshVertex, layer_mesh}; -pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; +pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; -pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; +pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay}; pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts}; /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..a869ebdb 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String { /// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by /// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same -/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole). +/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on +/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used +/// once; top-level shapes only (groups move whole). pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); let mut used_a = vec![false; a.shapes.len()]; @@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { x.kind_name() == y.kind_name() && x.geom.preset_name().is_some() && x.geom.preset_name() == y.geom.preset_name() - && text_of(x).trim().is_empty() == text_of(y).trim().is_empty() + && text_of(x).trim().is_empty() + && text_of(y).trim().is_empty() }, ]; for pass in passes { @@ -173,9 +176,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool { a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) } } +/// The content of a shape apart from what Morph interpolates, with a picture's crop left out. +fn uncropped(s: &Shape) -> Shape { + let mut c = content(s); + if let ShapeKind::Picture { fill } = &mut c.kind { + fill.crop = [0.0; 4]; + } + c +} + +fn crop_of(s: &Shape) -> [f64; 4] { + match &s.kind { + ShapeKind::Picture { fill } => fill.crop, + _ => [0.0; 4], + } +} + +/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph. +fn recropped(a: &Shape, b: &Shape) -> bool { + matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b) +} + +/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box +/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales +/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back +/// to blending the crops when a crop leaves nothing visible. +pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] { + // Along one axis: (box start, box size, crop before, crop after) → the crops at `t`. + let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| { + let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb); + if va <= 1e-6 || vb <= 1e-6 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + // The whole image: its extent and start on either slide. + let (ea, eb) = (sa / va, sb / vb); + let e = lerp(ea, eb, t); + if !e.is_finite() || e.abs() <= 1e-9 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + let o = lerp(pa - la * ea, pb - lb * eb, t); + let lo = (p - o) / e; + (lo, 1.0 - lo - s / e) + }; + let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w)); + let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h)); + let c = [l, tp, r, bt]; + if c.iter().all(|v| v.is_finite()) { c } else { cb } +} + /// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes /// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the -/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), +/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only +/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), /// otherwise the new shape fades in under the fading old one on the same moving /// box; with the transition option `words` or `characters` their texts are listed in /// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for @@ -223,8 +275,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: (_, xb) => xb, }; let reshape = reshaped(sa, sb); - if reshape || content(sa) == content(sb) { + let recrop = recropped(sa, sb); + if reshape || recrop || content(sa) == content(sb) { let mut m = Shape { xfrm: x, ..sb.clone() }; + if recrop && let ShapeKind::Picture { fill } = &mut m.kind { + fill.crop = match (xa, xb, x) { + (Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s), + _ => { + let (ca, cb) = (crop_of(sa), crop_of(sb)); + [lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)] + } + }; + } if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb)) && fa != fb { @@ -237,7 +299,11 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: } else { let new = push(Shape { xfrm: x, ..sb.clone() }, false, s); let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s); - if by_text + // The same text in shapes that differ otherwise (a title slide's centred title and a + // content slide's title: other placeholder, size, alignment) moves and resizes word + // by word even with the Objects option, as PowerPoint does, instead of cross-fading. + let same_text = text_of(sa).trim() == text_of(sb).trim(); + if (by_text || same_text) && has_text(sa) && has_text(sb) && let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) diff --git a/crates/anim/src/path.rs b/crates/anim/src/path.rs index ffe4f5e6..af61bd58 100644 --- a/crates/anim/src/path.rs +++ b/crates/anim/src/path.rs @@ -288,6 +288,27 @@ pub fn default_path(effect: &str, option: &str) -> String { } }, "loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(), - _ => String::new(), + other => deckcraft_model::anim::preset_path(other).unwrap_or_default(), + } +} + +#[cfg(test)] +mod preset_path_tests { + use super::*; + + #[test] + fn every_extra_motion_path_preset_starts_at_its_shape_and_moves() { + use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path}; + let extra: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && preset_path(a.0).is_some()).collect(); + assert!(extra.len() >= 30); + for a in extra { + assert_eq!(default_path(a.0, ""), preset_path(a.0).unwrap_or_default()); + let mp = MotionPath::parse(&default_path(a.0, "")); + let (x, y) = mp.at(0.0); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "{} starts at its shape", a.0); + let far = mp.points.iter().map(|p| p.0.abs() + p.1.abs()).fold(0.0, f64::max); + assert!(far > 0.05, "{} moves", a.0); + assert!(mp.points.iter().all(|p| p.0.abs() < 0.5 && p.1.abs() < 0.5), "{} stays near its shape", a.0); + } } } diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index dbc0a8ae..3d9c241f 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -1,10 +1,13 @@ //! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop //! and automatic advance. +use std::collections::BTreeMap; + use deckcraft_model::Presentation; -use deckcraft_model::text::Action; +use deckcraft_model::text::{Action, Hyperlink}; use crate::Timeline; +use crate::interact::SlideRun; /// What the UI should do after a navigation call. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -52,8 +55,10 @@ pub enum LinkJump { None, } -/// Where a slide show is. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +/// Where a slide show is. Besides the current position it remembers what was played on every slide +/// the show has visited (main steps, triggered sequences), so a slide reached again by a link or +/// the arrow keys looks the way it was left, as in PowerPoint. +#[derive(Clone, Debug, PartialEq, Default)] pub struct ShowState { /// Index into `presentation.slides`. pub slide: usize, @@ -63,6 +68,19 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// The show has entered a slide (so `slide` and `step` are meaningful and can be saved). + active: bool, + /// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks. + pub(crate) last_now: Option, + /// What was played on each visited slide (index into `presentation.slides`). + pub(crate) runs: BTreeMap, + /// One-shot visit order (Custom Show from the dialog or a click). `None` uses [`show_order`]. + order: Option>, + /// Name of the custom show being played, when there is one. + custom: Option, + /// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes + /// back to the zoom slide, fully built. + pub zoom: Option<(usize, usize)>, } /// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus @@ -83,6 +101,18 @@ pub fn show_order(pres: &Presentation) -> Vec { base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect() } +/// Visible slides of custom show `name`, in that show's order. +fn custom_indices(pres: &Presentation, name: &str) -> Option> { + let cs = pres.custom_shows.iter().find(|c| c.name == name)?; + let order: Vec = cs + .slides + .iter() + .filter_map(|id| pres.slides.iter().position(|s| s.id == *id)) + .filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)) + .collect(); + if order.is_empty() { None } else { Some(order) } +} + fn looping(pres: &Presentation) -> bool { pres.show.loop_until_esc || pres.show.show_type == "kiosk" } @@ -104,9 +134,48 @@ impl ShowState { } else { s.ended = true; } + if let Some(name) = pres.show.custom_show.clone() { + s.custom = Some(name); + } s } + /// Play custom show `name` from its first slide, without changing Set Up Slide Show. + /// `None` when that show is missing or has no visible slides. + pub fn play_custom(pres: &Presentation, name: &str) -> Option { + let order = custom_indices(pres, name)?; + let &first = order.first()?; + let mut s = ShowState::start_at(pres, first); + s.order = Some(order); + s.custom = Some(name.to_string()); + Some(s) + } + + /// Name of the custom show this playback is limited to. + pub fn custom_name(&self) -> Option<&str> { + self.custom.as_deref().filter(|s| !s.is_empty()) + } + + /// The slide that follows the current one in this show. + pub fn peek_next(&self, pres: &Presentation) -> Option { + self.next_index(pres) + } + + pub fn first_index(&self, pres: &Presentation) -> Option { + self.visit(pres).first().copied() + } + + pub fn last_index(&self, pres: &Presentation) -> Option { + self.visit(pres).last().copied() + } + + fn visit(&self, pres: &Presentation) -> Vec { + match &self.order { + Some(order) => order.iter().copied().filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect(), + None => show_order(pres), + } + } + /// The timeline of slide `i`. pub fn timeline(pres: &Presentation, i: usize) -> Timeline { match pres.slides.get(i) { @@ -120,15 +189,41 @@ impl ShowState { if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() } } + /// Save what was played on the current slide before the show moves away from it. A main step + /// that is playing is taken to its end. Triggered animations keep their place: their clock only + /// runs while the slide is shown, and they go on from there when the show comes back. + fn leave(&mut self, pres: &Presentation) { + if !self.active { + return; + } + let done = if self.playing { self.step + 1 } else { self.step }; + let step = done.min(ShowState::steps(pres, self.slide)); + self.runs.entry(self.slide).or_default().step = step; + self.playing = false; + } + + /// Show slide `i`: as it was left when the show has been there before, otherwise from the start. fn enter(&mut self, pres: &Presentation, i: usize) { + self.leave(pres); + self.active = true; self.slide = i; - self.step = 0; self.ended = false; - self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); + // Time spent elsewhere never counts for this slide's triggered animations. + self.last_now = None; + let steps = ShowState::steps(pres, i); + let run = self.runs.entry(i).or_default(); + if run.visited { + self.step = run.step.min(steps); + self.playing = false; + } else { + run.visited = true; + self.step = 0; + self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); + } } fn next_index(&self, pres: &Presentation) -> Option { - let order = show_order(pres); + let order = self.visit(pres); match order.iter().position(|&i| i == self.slide) { Some(p) => order.get(p + 1).copied(), None => order.iter().copied().find(|&i| i > self.slide), @@ -136,7 +231,7 @@ impl ShowState { } fn prev_index(&self, pres: &Presentation) -> Option { - let order = show_order(pres); + let order = self.visit(pres); match order.iter().position(|&i| i == self.slide) { Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()), None => order.iter().copied().rev().find(|&i| i < self.slide), @@ -160,12 +255,22 @@ impl ShowState { self.playing = true; return ShowAction::PlayStep(self.step); } + if let Some((back, until)) = self.zoom + && until == self.slide + && back < pres.slides.len() + { + self.zoom = None; + self.enter(pres, back); + self.step = ShowState::steps(pres, back); + self.playing = false; + return ShowAction::Slide(back); + } match self.next_index(pres) { Some(i) => { self.enter(pres, i); ShowAction::Slide(i) } - None if looping(pres) => match show_order(pres).first() { + None if looping(pres) => match self.visit(pres).first() { Some(&i) => { self.enter(pres, i); ShowAction::Slide(i) @@ -215,14 +320,19 @@ impl ShowState { } let target = match self.prev_index(pres) { Some(i) => Some(i), - None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide), + None if looping(pres) => self.visit(pres).last().copied().filter(|&i| i != self.slide), None => None, }; match target { Some(i) => { - self.slide = i; - self.step = ShowState::steps(pres, i); - self.playing = false; + // A slide the show has been to looks as it was left; one it has not been to is + // shown fully built. + let seen = self.runs.get(&i).is_some_and(|r| r.visited); + self.enter(pres, i); + if !seen { + self.step = ShowState::steps(pres, i); + self.playing = false; + } ShowAction::Slide(i) } None => ShowAction::None, @@ -234,6 +344,7 @@ impl ShowState { if i >= pres.slides.len() { return ShowAction::None; } + self.zoom = None; self.enter(pres, i); ShowAction::Slide(i) } @@ -247,11 +358,18 @@ impl ShowState { Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide), Action::NextSlide => self.next_index(pres), Action::PreviousSlide => self.prev_index(pres), - Action::FirstSlide => show_order(pres).first().copied(), - Action::LastSlide => show_order(pres).last().copied(), + Action::FirstSlide => self.visit(pres).first().copied(), + Action::LastSlide => self.visit(pres).last().copied(), Action::LastViewed => last_viewed, - // Custom shows, programs and media actions aren't followed (yet). - Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None, + Action::CustomShow { name, .. } => { + let Some(order) = custom_indices(pres, name) else { return LinkJump::None }; + let Some(&i) = order.first() else { return LinkJump::None }; + self.order = Some(order); + self.custom = Some(name.clone()); + return LinkJump::Show(self.goto(pres, i)); + } + // Programs and media actions aren't followed. + Action::Program { .. } | Action::PlayMedia => None, }; match target { Some(i) => LinkJump::Show(self.goto(pres, i)), @@ -259,6 +377,18 @@ impl ShowState { } } + /// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]). + pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option) -> LinkJump { + let from = self.slide; + let jump = self.follow(pres, &link.action, last_viewed); + if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id)) + && matches!(jump, LinkJump::Show(ShowAction::Slide(_))) + { + self.zoom = Some((from, until)); + } + jump + } + /// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the /// show uses timings. pub fn advance_after(&self, pres: &Presentation) -> Option { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..1ac69104 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,61 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_pairs_leaves_unrelated_text_boxes_alone() { + // A text box that leaves and an unrelated one that arrives fade in place (#119). + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) }; + a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")]; + b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")]; + assert!(morph_pairs(&a, &b).is_empty()); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect(); + assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]); + // Empty shapes of one kind and geometry still pair, and a named text box keeps its partner. + a.shapes.push(shape(3, "Rect A")); + b.shapes.push(shape(4, "Rect B")); + b.shapes[0].name = "TextBox 1".into(); + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]); +} + +#[test] +fn morph_crop_moves_the_image_linearly() { + let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0); + // Same box, the crop pans from the left half to the right half of a 200-wide image. + let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // The box shrinks over an image that stays put: only the right crop grows. + let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5); + assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // Nothing visible on one side: the crops blend. + let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}"); +} + +#[test] +fn morph_frame_morphs_a_picture_crop() { + // A picture recropped on the next slide stays one picture whose crop morphs (#118). + let p = deckcraft_model::defaults::new_presentation(None); + let pic = |id, crop| Shape { + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } }, + ..shape(id, "Picture 1") + }; + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])]; + b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])]; + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert_eq!(f.shapes.len(), 1); + assert!(f.opacity.is_empty()); + let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") }; + let want = 0.5 * f.mix; + assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop); + assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; @@ -702,6 +757,37 @@ fn show_custom_show_and_range() { assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]); } +#[test] +fn playing_a_custom_show_stays_on_its_slides() { + use deckcraft_model::text::Action; + let mut p = pres(5); + p.custom_shows = vec![CustomShow { name: "Ends".into(), slides: vec![SlideId(1000), SlideId(1004)] }]; + assert!(p.show.custom_show.is_none()); + let mut s = ShowState::play_custom(&p, "Ends").expect("the show exists"); + assert_eq!(s.custom_name(), Some("Ends")); + assert_eq!(s.slide, 0); + assert_eq!(s.peek_next(&p), Some(4)); + assert_eq!(s.next(&p), ShowAction::Slide(4)); + assert_eq!(s.next(&p), ShowAction::End); + assert_eq!(s.prev(&p), ShowAction::Slide(4)); + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + assert!(ShowState::play_custom(&p, "Missing").is_none()); + assert!(p.show.custom_show.is_none(), "one-shot playback does not change Set Up"); + + Arc::make_mut(&mut p.slides[4]).hidden = true; + let only = ShowState::play_custom(&p, "Ends").expect("the visible slide remains"); + assert_eq!(only.first_index(&p), Some(0)); + assert_eq!(only.last_index(&p), Some(0)); + + let mut s = ShowState::new(&p); + p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() }); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into(), returns: true }, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!(s.custom_name(), Some("Ends")); + assert_eq!(s.next(&p), ShowAction::Slide(4)); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into(), returns: true }, None), LinkJump::None); + assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide"); +} + #[test] fn show_steps_through_builds() { let mut p = pres(2); @@ -789,3 +875,156 @@ fn show_follows_links() { assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); assert_eq!(s.slide, 3); } + +/// #420: late in the Honeycomb every cell has settled, so the incoming hexagons must cover the +/// whole slide — the old row count stopped at ~75% of a 16:9 slide and left the bottom black. +#[test] +fn honeycomb_cells_reach_the_bottom_before_the_slide_ends() { + let sign = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1]); + let inside = |p: [f64; 2], t: [[f64; 2]; 3]| { + // The dark seams between cells are the effect: at t=0.99 they are ~0.012% of the width + // open, so a 1% pad forgives them and still fails genuinely uncovered ground. + let c = [(t[0][0] + t[1][0] + t[2][0]) / 3.0, (t[0][1] + t[1][1] + t[2][1]) / 3.0]; + let t = t.map(|v| [c[0] + (v[0] - c[0]) * 1.01, c[1] + (v[1] - c[1]) * 1.01]); + let (d1, d2, d3) = (sign(p, t[0], t[1]), sign(p, t[1], t[2]), sign(p, t[2], t[0])); + !((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0)) + }; + for aspect in [16.0 / 9.0, 3.0 / 2.0, 4.0 / 3.0, 11.0 / 8.5, 1.0] { + // t = 0.99: every cell is settled and the seams are nearly closed. + let ls = transition_frame("honeycomb", "", 0.99, aspect); + let tris: Vec<[[f64; 2]; 3]> = + ls.iter().filter(|l| l.source == Source::New && l.alpha >= 1.0).map(|l| [l.quad[0], l.quad[1], l.quad[2]]).collect(); + assert!(!tris.is_empty()); + let mut sy = 0.0; + while sy <= 1.0 { + for sx in [0.0, 0.2, 0.4, 0.6, 0.8, 0.99, 1.0] { + let p = [sx, sy]; + assert!(tris.iter().any(|t| inside(p, *t)), "honeycomb aspect {aspect} t=0.99: {sx},{sy} is uncovered (no settled cell reaches it)"); + } + sy += 0.02; + } + } +} + +/// #147: exits PowerPoint plays downward leave downward here too. +#[test] +fn sink_down_and_peek_out_to_bottom_move_down() { + for (effect, option) in [("sinkDown", ""), ("peekOut", "b"), ("floatOut", "d")] { + let mut a = anim(1, AnimClass::Exit, effect, AnimStart::OnClick); + a.option = option.into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let s = tl.state(ShapeId(1), None, 0, 0.5); + assert!(s.offset_y > 0.0, "{effect} {option}: {s:?}"); + } +} + +#[test] +fn trigger_sequences_keep_first_appearance_order_when_interleaved() { + let mut anims = vec![]; + for (i, tr) in [7u32, 5, 7, 0, 9, 5].iter().enumerate() { + let mut a = anim(1 + (i as u32 % 4), AnimClass::Entrance, "fade", AnimStart::OnClick); + a.trigger = (*tr != 0).then_some(ShapeId(*tr)); + anims.push(a); + } + let tl = Timeline::new(&slide_with(anims), W, H); + assert_eq!(tl.trigger_shapes(), vec![ShapeId(7), ShapeId(5), ShapeId(9)]); + assert_eq!(tl.trigger_steps(ShapeId(7)), 2); + assert_eq!(tl.trigger_steps(ShapeId(5)), 2); + assert_eq!(tl.trigger_steps(ShapeId(9)), 1); + assert_eq!(tl.steps(), 1); +} + +#[test] +fn many_distinct_triggers_past_the_effect_cap_build_in_order() { + // One sequence per animation: grouping used to rescan every animation per trigger + // (~1.2e10 steps here), now it is one pass, and nothing past the cap is looked at. + let n = 110_000u32; + let anims: Vec = (0..n) + .map(|i| { + let mut a = anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick); + a.trigger = Some(ShapeId(1000 + i)); + a + }) + .collect(); + let tl = Timeline::new(&slide_with(anims), W, H); + assert_eq!(tl.effects().len(), 100_000); + let shapes = tl.trigger_shapes(); + assert_eq!(shapes.len(), 100_000); + assert!(shapes.iter().enumerate().all(|(i, s)| *s == ShapeId(1000 + i as u32))); +} + +/// #316: the same text in two shapes that differ otherwise (a title slide's big centred title, +/// a content slide's title) morphs word by word with the default Objects option, instead of +/// the old box cross-fading into the new one. +#[test] +fn same_text_morphs_even_by_objects() { + let p = deckcraft_model::defaults::new_presentation(None); + let titled = |id, x, size: f64, text: &str| { + let mut body = TextBody::from_text(text); + for para in &mut body.paragraphs { + for r in &mut para.runs { + r.props.size = Some(size); + } + } + Shape { xfrm: Some(Xfrm::new(x, 100.0, 600.0, 120.0)), text: Some(body), ..shape(id, "Title 1") } + }; + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![titled(1, 100.0, 60.0, "TEST")]; + b.shapes = vec![titled(2, 40.0, 44.0, "TEST")]; + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert_eq!(f.text.len(), 1, "the shared text morphs"); + assert!(!f.text[0].chars); + // Different texts still cross-fade with the Objects option. + b.shapes = vec![titled(2, 40.0, 44.0, "Other")]; + assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty()); +} + +#[test] +fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() { + // #116: a video set to play in the click sequence starts with its step, and the step doesn't + // last as long as the video (the next click goes on while it plays). + let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) }; + let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H); + assert_eq!(tl.steps(), 2); + assert_eq!(tl.step_duration(0), 0.0); + let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0); + assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::>(), [(ShapeId(1), 0)]); + assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once"); + assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects"); + // An After Previous effect still waits for the video. + let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H); + assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0)); +} + +/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully +/// built; without it, or after jumping elsewhere, the show carries on in order. +#[test] +fn show_returns_to_the_zoom_slide() { + use deckcraft_model::text::{Action, Hyperlink}; + let p = pres(6); + let link = |to: u32, last: Option| Hyperlink { + action: Action::Slide { slide: SlideId(1000 + to) }, + tooltip: String::new(), + highlight_click: false, + zoom_return: last.map(|l| SlideId(1000 + l)), + sound: None, + stop_sound: false, + }; + let mut s = ShowState::new(&p); + // A section zoom on slide 0 for slides 2–3. + assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.next(&p), ShowAction::Slide(3)); + assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom"); + assert_eq!(s.step, ShowState::steps(&p, 0)); + assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order"); + // Without Return to Zoom the show carries on. + let mut s = ShowState::new(&p); + s.follow_link(&p, &link(4, None), None); + assert_eq!(s.next(&p), ShowAction::Slide(5)); + // Jumping elsewhere (See All Slides, a number) forgets the zoom. + let mut s = ShowState::new(&p); + s.follow_link(&p, &link(2, Some(2)), None); + s.goto(&p, 4); + assert_eq!(s.next(&p), ShowAction::Slide(5)); +} diff --git a/crates/anim/src/tests_interact.rs b/crates/anim/src/tests_interact.rs new file mode 100644 index 00000000..5f12dda5 --- /dev/null +++ b/crates/anim/src/tests_interact.rs @@ -0,0 +1,364 @@ +//! Clicks, triggered animations and what the show remembers about visited slides. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use deckcraft_model::text::Action; +use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm}; + +use crate::*; + +fn shape(id: u32) -> Shape { + Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() } +} + +/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`. +fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation { + let effect = if ms == 0 { "disappear" } else { "fadeOut" }; + Animation { + shape: ShapeId(target), + class: AnimClass::Exit, + effect: effect.into(), + start: AnimStart::OnClick, + duration_ms: ms, + trigger: Some(ShapeId(trigger)), + ..Default::default() + } +} + +fn slide(id: u32, shapes: Vec, animations: Vec, advance_on_click: bool) -> Arc { + let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() }); + Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() }) +} + +fn to_slide(n: u32) -> Option { + Some(Action::Slide { slide: SlideId(n) }) +} + +fn hit(id: u32, link: Option) -> HitShape { + HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true } +} + +/// Test driver: a show plus the click helpers the slide show window uses. +struct Run { + pres: Presentation, + show: ShowState, + /// Hit shapes of each slide, back to front. + hits: Vec>, + now: f64, +} + +impl Run { + fn new(pres: Presentation, hits: Vec>) -> Run { + let show = ShowState::new(&pres); + Run { pres, show, hits, now: 0.0 } + } + + fn tl(&self) -> Timeline { + ShowState::timeline(&self.pres, self.show.slide) + } + + fn visible(&self, id: ShapeId) -> bool { + let st = self.show.anim_state(&self.tl(), id, 0.0); + st.visible && st.opacity > 0.0 + } + + fn opacity(&self, id: u32) -> f64 { + self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity + } + + fn click(&mut self) -> Click { + let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone()); + let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect(); + let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1); + self.show.click(&self.pres, &tl, &hits, &visible, self.now, None) + } + + fn target(&self) -> Option { + let (tl, hits) = (self.tl(), &self.hits[self.show.slide]); + self.show.target(&self.pres, &tl, hits, &|id| self.visible(id)) + } + + /// Let `secs` pass on the shown slide, in small frames. + fn wait(&mut self, secs: f64) { + let end = self.now + secs; + while self.now < end - 1e-9 { + self.now = (self.now + 0.05).min(end); + let tl = self.tl(); + self.show.tick(&tl, self.now); + } + } +} + +/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a +/// picture on top of both that disappears (4); slide 2 has a button back to slide 1. +fn two_slides(advance_on_click: bool) -> Run { + let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click); + let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]]) +} + +#[test] +fn hidden_shapes_stay_hidden_when_the_show_comes_back() { + let mut r = two_slides(false); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top"); + // Click the picture: it disappears; the button beneath it is now the target. + assert_eq!(r.click(), Click::Animated); + assert!(!r.visible(ShapeId(4))); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2))); + // Click the button: its animation starts and the link is followed. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + assert_eq!(r.show.slide, 1); + // Slide 2: the button goes back. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + assert_eq!(r.show.slide, 0); + // Both are still gone, and nothing on the slide can be clicked any more. + assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3))); + assert_eq!(r.target(), None); + // "Advance on click" is off: a click anywhere does nothing, however often. + for _ in 0..3 { + assert_eq!(r.click(), Click::None); + } + assert_eq!(r.show.slide, 0); +} + +#[test] +fn background_click_advances_when_the_slide_allows_it() { + let mut r = two_slides(true); + r.click(); // the picture disappears + r.click(); // the button disappears and links to slide 2 + r.click(); // the button there links back + assert_eq!(r.show.slide, 0); + assert_eq!(r.target(), None); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); +} + +#[test] +fn advance_on_click_off_still_plays_animation_steps() { + let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() }; + let s1 = slide(1000, vec![shape(1)], vec![main], false); + let s2 = slide(1001, vec![], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]); + // The first click plays the step, the next finishes it; then the slide stays. + assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0)))); + assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0)))); + assert_eq!(r.click(), Click::None); + assert_eq!(r.show.slide, 0); + // The arrow keys still move on. + assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1)); +} + +#[test] +fn a_shape_without_an_action_blocks_the_one_beneath_it() { + let s = slide(1000, vec![shape(1), shape(2)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]); + assert_eq!(r.target(), None, "the plain shape on top blocks the button"); + // So the click is a background click, not the button's link. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + // Reversed, the button is on top and is the target. + let s = slide(1000, vec![shape(2), shape(1)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); +} + +#[test] +fn groups_answer_for_their_children() { + let s = slide(1000, vec![shape(1)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true }; + let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true }; + // A child of a group with an action acts as the group. + let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10))); + // Empty space inside the box of a group without an action blocks nothing. + let behind = hit(5, to_slide(1001)); + let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5))); +} + +#[test] +fn pointer_outside_a_shape_is_not_on_it() { + let s = slide(1000, vec![shape(1)], vec![], true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let mut h = hit(1, to_slide(1000)); + h.under_pointer = false; + let r = Run::new(pres, vec![vec![h]]); + assert_eq!(r.target(), None); +} + +#[test] +fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() { + // Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s. + let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); + assert_eq!(r.click(), Click::Animated); + r.wait(2.0); + assert!(r.show.trigger_playing()); + assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade"); + assert!(r.visible(ShapeId(3))); + // A click on the shape while its step plays completes the step at once... + assert_eq!(r.click(), Click::Animated); + assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing()); + // ...and the next click plays the next step. + assert_eq!(r.click(), Click::Animated); + r.wait(5.2); + assert!(!r.visible(ShapeId(3))); + // All steps have played, and the shape still answers: its sequence starts over. + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); + assert_eq!(r.click(), Click::Animated); + assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone"); + r.wait(5.2); + assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3))); +} + +#[test] +fn a_ring_of_triggers_goes_round_and_round() { + // Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again. + let ring = 4u32; + let anims: Vec = (1..=ring) + .flat_map(|k| { + let next = k % ring + 1; + let show = Animation { + shape: ShapeId(next), + class: AnimClass::Entrance, + effect: "appear".into(), + start: AnimStart::WithPrevious, + duration_ms: 0, + trigger: Some(ShapeId(k)), + ..Default::default() + }; + [exit_on_click(k, k, 0), show] + }) + .collect(); + let shapes: Vec = (1..=ring).map(shape).collect(); + let s = slide(1000, shapes, anims, true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let hits: Vec = (1..=ring).map(|k| hit(k, None)).collect(); + let mut r = Run::new(pres, vec![hits]); + let showing = |r: &Run| -> Vec { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() }; + assert_eq!(showing(&r), vec![1]); + for lap in 0..3 { + for k in 1..=ring { + assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}"); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k))); + assert_eq!(r.click(), Click::Animated); + } + } + assert_eq!(showing(&r), vec![1]); +} + +#[test] +fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() { + // Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2. + let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false); + let s2 = slide(1001, vec![shape(5)], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]); + // Click the animated shape, wait 2 s, click the link. + r.click(); + r.wait(2.0); + assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0); + // The second shape is not over the first one, so click the link directly. + let tl = r.tl(); + let hits = vec![hit(2, to_slide(1001))]; + assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1))); + // However long the show stays away, the animation does not run. + r.wait(60.0); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + // Back on the slide it has 3 s left. + assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1); + assert!(r.show.trigger_playing()); + r.wait(2.5); + assert!(r.show.trigger_playing() && r.opacity(4) > 0.0); + r.wait(1.0); + assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4))); +} + +#[test] +fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() { + let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false); + let s2 = slide(1001, vec![shape(5)], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + r.wait(30.0); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + // The whole animation is still ahead. + assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95); + assert!(r.show.trigger_playing()); + r.wait(5.5); + assert!(!r.visible(ShapeId(2))); +} + +#[test] +fn a_slide_comes_back_the_way_it_was_left() { + let steps: Vec = (1..=3) + .map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() }) + .collect(); + let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true); + let s2 = slide(1001, vec![], vec![], true); + let s3 = slide(1002, vec![], vec![], true); + let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() }; + let mut s = ShowState::new(&pres); + s.next(&pres); + s.step_done(&pres); + assert_eq!(s.step, 1); + // A link away and back: still after step 1. + assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!((s.step, s.playing), (1, false)); + // So does the back arrow. + s.goto(&pres, 1); + assert_eq!(s.prev(&pres), ShowAction::Slide(0)); + assert_eq!(s.step, 1); + // A slide the show has not been to is entered at its start; going back to one shows it fully + // built. + let mut s = ShowState::new(&pres); + s.goto(&pres, 2); + assert_eq!(s.prev(&pres), ShowAction::Slide(1)); + assert_eq!(s.step, ShowState::steps(&pres, 1)); +} + +#[test] +fn a_playing_main_step_is_finished_when_the_show_leaves() { + let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() }; + let s1 = slide(1000, vec![shape(1)], vec![a], true); + let s2 = slide(1001, vec![], vec![], true); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut s = ShowState::new(&pres); + assert_eq!(s.next(&pres), ShowAction::PlayStep(0)); + s.goto(&pres, 1); + s.goto(&pres, 0); + assert_eq!((s.step, s.playing), (1, false)); +} + +#[test] +fn show_without_animation_ignores_triggers() { + let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true); + let mut pres = Presentation { slides: vec![s], ..Default::default() }; + pres.show.without_animation = true; + let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]); + assert_eq!(r.target(), None); +} + +#[test] +fn clocks_and_pointers_with_hostile_numbers_are_harmless() { + let mut r = two_slides(true); + let tl = r.tl(); + for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] { + r.show.tick(&tl, now); + let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None); + } + // A parent index that points nowhere, or in a circle, is not followed forever. + let bad = vec![ + HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true }, + HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true }, + ]; + assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index e73acfd9..98e91f96 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -1,5 +1,7 @@ //! A slide's animation sequence: click steps, effect start times and per-shape state over time. +use std::collections::{HashMap, HashSet}; + use deckcraft_model::anim::TextBuild; use deckcraft_model::style::Fill; use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm}; @@ -57,6 +59,16 @@ pub struct TriggerPlay { pub t: f64, } +/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects +/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again +/// by another ends up as the later of the two left it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StepStamp { + pub trigger: ShapeId, + pub step: usize, + pub at: u64, +} + #[derive(Clone, Debug, Default)] struct StepInfo { auto: bool, @@ -151,25 +163,31 @@ impl Timeline { let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let mut tl = Timeline::default(); - // Sequences in order of first appearance: main first. - let mut seqs: Vec = vec![Sequence::Main]; - for a in &slide.animations { - if let Some(tr) = a.trigger { - let s = Sequence::Trigger(tr); - if !seqs.contains(&s) { - seqs.push(s); - } + // Group the animations by sequence in one pass, sequences in order of first appearance + // (main first), so many distinct triggers cost linear work, not a scan per trigger. + let mut seqs: Vec<(Sequence, Vec)> = vec![(Sequence::Main, Vec::new())]; + let mut seq_index: HashMap = HashMap::from([(Sequence::Main, 0)]); + for (ai, a) in slide.animations.iter().enumerate() { + let s = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main); + let i = *seq_index.entry(s).or_insert_with(|| { + seqs.push((s, Vec::new())); + seqs.len() - 1 + }); + if let Some((_, members)) = seqs.get_mut(i) { + members.push(ai); } } - for seq in seqs { + // Paragraph groups already recorded (the list itself keeps their order). + let mut grouped: HashSet<(ShapeId, usize, usize)> = HashSet::new(); + for (seq, members) in seqs { + if tl.effects.len() >= MAX_EFFECTS { + break; + } let mut steps: Vec = Vec::new(); let mut group_begin = 0.0f64; let mut max_end = 0.0f64; - for (ai, a) in slide.animations.iter().enumerate() { - let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main); - if aseq != seq { - continue; - } + for ai in members { + let Some(a) = slide.animations.get(ai) else { continue }; if tl.effects.len() >= MAX_EFFECTS { break; } @@ -188,7 +206,7 @@ impl Timeline { owner = Some(i); v.push(Some(i)); } else if let Some(o) = owner - && !tl.groups.contains(&(a.shape, i, o)) + && grouped.insert((a.shape, i, o)) { tl.groups.push((a.shape, i, o)); } @@ -249,7 +267,9 @@ impl Timeline { max_end = max_end.max(end); let step = steps.len().saturating_sub(1); if let Some(s) = steps.last_mut() { - s.duration = s.duration.max(end); + // Media plays on by itself once started: it holds back an After Previous + // effect (`max_end`), but not the next click (#116). + s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end }); } tl.effects.push(Effect { timing: EffectTiming { @@ -308,6 +328,20 @@ impl Timeline { self.main.get(i).map(|s| s.duration).unwrap_or(0.0) } + /// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to` + /// seconds into it, in order: what a show running the step from `from` to `to` sets off. + pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec { + let mut v: Vec = self + .effects + .iter() + .map(|e| &e.timing) + .filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to) + .cloned() + .collect(); + v.sort_by(|a, b| a.start.total_cmp(&b.start)); + v + } + /// Total duration of the main sequence played without pauses. pub fn total_duration(&self) -> f64 { self.main.iter().map(|s| s.duration).sum() @@ -379,6 +413,21 @@ impl Timeline { /// [`Timeline::state`] with the playheads of triggered sequences. pub fn state_with(&self, shape: ShapeId, para: Option, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState { + self.state_ordered(shape, para, step, t, triggers, &[]) + } + + /// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps + /// were started (`stamps`), after those of the main sequence. Without stamps they apply in + /// Animation Pane order. + pub fn state_ordered( + &self, + shape: ShapeId, + para: Option, + step: usize, + t: f64, + triggers: &[TriggerPlay], + stamps: &[StepStamp], + ) -> AnimState { let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p)); let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); @@ -391,7 +440,16 @@ impl Timeline { { st.visible = false; } - // Then play in time order: main sequence first, then triggered sequences. + // Then play in time order: main sequence first, then triggered sequences as they were clicked. + if !stamps.is_empty() { + let when = |e: &Effect| match e.timing.sequence { + Sequence::Main => 0, + Sequence::Trigger(tr) => { + stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1)) + } + }; + ordered.sort_by_key(|e| (when(e), e.timing.anim)); + } for e in &ordered { let (pstep, pt) = match e.timing.sequence { Sequence::Main => (step, t), @@ -471,7 +529,8 @@ fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckc EffectSpec { class: a.class, effect, - option: a.option.clone(), + // Sink Down is Rise Up played backwards, leaving downward. + option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() }, accel: a.smooth_start, decel: a.smooth_end, bounce: a.bounce_end, diff --git a/crates/anim/src/transition_fx.rs b/crates/anim/src/transition_fx.rs index d5fb2e16..3bbc4a8d 100644 --- a/crates/anim/src/transition_fx.rs +++ b/crates/anim/src/transition_fx.rs @@ -794,7 +794,11 @@ fn honeycomb(t: f64, a: f64) -> Vec { let cols = 11usize; let w = 1.0 / cols as f64; let rh = w * a * 0.5; - let rows = (1.0 / (rh * 1.5)).ceil() as usize + 2; + // Rows sit 0.866*rh apart; a settled hex reaches only 0.577*rh from its centre at the side + // vertices, and the flat bottom zone 0.289*rh. Count the rows whose FLAT zone still covers + // the slide's bottom: counting the vertex reach instead left V-notches of black above the + // bottom edge on about a third of the aspect ratios (#420). + let rows = ((1.0 / (rh * 0.866) - 1.0 / 3.0).ceil().max(1.0)) as usize + 1; let mut v = vec![Layer::full(Black, 1.0)]; let seam = ss(win(t, 0.0, 0.12)) * (1.0 - ss(win(t, 0.85, 1.0))); let hex = |cx: f64, cy: f64, s: f64| -> Vec<[f64; 2]> { diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index 66c96698..06de1589 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -206,12 +206,35 @@ pub struct Decoder { /// Frames submitted to the workers and not known to be finished (oldest first). in_flight: VecDeque>, max_in_flight: usize, + /// Bytes the picture store may take ([`deckcraft_bitstream::budget`]). + memory_budget: u64, /// Shown pictures not yet returned, in output order. out: VecDeque, /// pts of the temporal unit being decoded. pts: i64, } +/// Whether `pictures` frames of `width` x `height`, padded as `FrameBuf::new` pads them, fit in +/// `budget` bytes. +fn store_fits(width: u32, height: u32, color: &crate::header::ColorConfig, pictures: usize, budget: u64) -> bool { + let shape = deckcraft_bitstream::budget::PictureShape { + width: u64::from(width).div_ceil(128) * 128 + 160, + height: u64::from(height).div_ceil(128) * 128 + 160, + shift_x: u32::from(color.subsampling_x), + shift_y: u32::from(color.subsampling_y), + chroma_planes: if color.mono_chrome { 0 } else { 2 }, + bytes_per_sample: 2, + }; + shape.fits(pictures as u64, budget) +} + +impl Decoder { + /// Cap the bytes of decoded pictures (references, current, in flight) this decoder may hold. + pub fn set_memory_budget(&mut self, bytes: u64) { + self.memory_budget = bytes; + } +} + fn default_threads() -> usize { #[cfg(all(feature = "threads", not(target_arch = "wasm32")))] { @@ -268,6 +291,7 @@ impl Decoder { workers, in_flight: VecDeque::new(), max_in_flight: fthreads + 2, + memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET, out: VecDeque::new(), pts: 0, } @@ -508,6 +532,12 @@ impl Decoder { if fh.upscaled_width > 65536 || fh.frame_height > 65536 { return Err(Error::Invalid("frame size")); } + // The axis cap alone allows 8 GiB pictures: check the whole store (every reference slot, + // the new frame and those in flight, as `FrameBuf::new` pads them) before allocating. + let width = fh.upscaled_width.max(fh.frame_width); + if !store_fits(width, fh.frame_height, &seq.color, NUM_REF_FRAMES + 1 + self.max_in_flight, self.memory_budget) { + return Err(Error::Invalid("frame size exceeds the decoder memory budget")); + } if !fh.frame_is_intra { for i in 0..REFS_PER_FRAME { if self.ref_frames[fh.ref_frame_idx[i]].is_none() { @@ -555,6 +585,7 @@ impl Decoder { if tg_end >= num_tiles || tg_start > tg_end { return Err(Error::Invalid("tile group range")); } + check_tile_group_start(job.tiles.len(), tg_start)?; let mut pos = r.byte_pos(); for tile_num in tg_start..=tg_end { let size = if tile_num == tg_end { @@ -633,7 +664,9 @@ impl Decoder { /// Decode a frame job and publish its results. fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex) { let mut st = DecodeStats::default(); - match decode_frame(&job, pool, &mut st) { + // A panic while decoding must still publish an error: the output and every frame that + // references this one wait on these slots. + match guarded(|| decode_frame(&job, pool, &mut st)) { Ok((rf, pic)) => { if let Some(ps) = &job.picture { ps.set(pic.map(Arc::new).ok_or(Error::Invalid("shown frame without picture"))); @@ -652,6 +685,12 @@ fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex) { } } +/// Run `f`, turning a panic into an error (the decoder's own code never panics on purpose; this +/// keeps a bug from stranding the job's waiters). +fn guarded(f: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(Error::Invalid("frame decode panicked"))) +} + /// The whole decoding process of one frame: setup, tiles, post-filters, motion vector storage /// and the output picture. fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<(Arc, Option)> { @@ -947,3 +986,84 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool { } true } + +/// Tile groups must arrive in order with no gaps and no overlap: each one starts at the tile +/// after the last one received (`tg_start` semantics, 6.10.1). +fn check_tile_group_start(received: usize, tg_start: usize) -> Result<()> { + if tg_start == received { Ok(()) } else { Err(Error::Invalid("tile group out of order")) } +} + +#[cfg(test)] +mod tests { + use super::check_tile_group_start; + + #[test] + fn tile_groups_must_be_contiguous() { + assert!(check_tile_group_start(0, 0).is_ok()); + assert!(check_tile_group_start(2, 2).is_ok()); + // duplicate coverage + assert!(check_tile_group_start(2, 0).is_err()); + assert!(check_tile_group_start(2, 1).is_err()); + // skipped tiles + assert!(check_tile_group_start(0, 1).is_err()); + assert!(check_tile_group_start(2, 3).is_err()); + } +} + +#[cfg(test)] +mod guard_tests { + use super::*; + + #[test] + fn a_panicking_job_still_publishes_an_error_to_its_waiters() { + let slot: Arc> = Slot::new(); + let picture: Arc> = Slot::new(); + // Waiters: the output and a dependent frame, on their own threads. + let (tx, rx) = std::sync::mpsc::channel(); + for s in [slot.clone(), picture.clone()] { + let tx = tx.clone(); + std::thread::spawn(move || { + let _ = tx.send(s.wait().is_err()); + }); + } + let (s, p) = (slot.clone(), picture.clone()); + std::thread::spawn(move || { + let r: Result = guarded(|| panic!("hostile frame")); + p.set(r.clone()); + s.set(r); + }); + for _ in 0..2 { + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(10)), Ok(true)); + } + assert_eq!(guarded(|| Ok(5)).ok(), Some(5)); + } +} + +#[cfg(test)] +mod budget_tests { + use super::*; + use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET; + + fn color(mono: bool) -> crate::header::ColorConfig { + crate::header::ColorConfig { mono_chrome: mono, subsampling_x: 1, subsampling_y: 1, ..Default::default() } + } + + #[test] + fn the_axis_maximum_is_over_budget_and_4k_is_not() { + let refs = NUM_REF_FRAMES + 1 + 4; + assert!(!store_fits(65536, 65536, &color(false), refs, DECODE_MEMORY_BUDGET)); + assert!(!store_fits(65536, 65536, &color(true), 1, DECODE_MEMORY_BUDGET)); + assert!(store_fits(3840, 2160, &color(false), refs, DECODE_MEMORY_BUDGET)); + assert!(store_fits(1920, 1080, &color(false), refs, DECODE_MEMORY_BUDGET)); + } + + #[test] + fn exact_budget_boundary_and_reference_count() { + // 8x8 pads to 288x288: luma 82944 + chroma 2 * 20736 samples, 2 bytes each. + let one = (288 * 288 + 2 * 144 * 144) * 2; + assert!(store_fits(8, 8, &color(false), 9, 9 * one)); + assert!(!store_fits(8, 8, &color(false), 9, 9 * one - 1)); + assert!(!store_fits(8, 8, &color(false), 10, 9 * one)); + assert!(store_fits(8, 8, &color(true), 9, 9 * 288 * 288 * 2)); + } +} diff --git a/crates/av1/src/grain.rs b/crates/av1/src/grain.rs index d4baad62..4222c672 100644 --- a/crates/av1/src/grain.rs +++ b/crates/av1/src/grain.rs @@ -5,6 +5,39 @@ use crate::header::{FilmGrainParams, SequenceHeader}; use crate::spec_tables::{GAUSSIAN_SEQUENCE, MC_IDENTITY}; #[inline] +/// One plane's scaling lookup (7.18.3.4) from its `n` points. The header rejects points whose +/// values don't strictly increase; a pair that still doesn't (defence in depth) is skipped +/// instead of dividing by zero. No points: all zero. +fn scaling_lut(n: usize, xs: &[u8], ys: &[u8]) -> [i32; 256] { + let mut l = [0i32; 256]; + let n = n.min(xs.len()).min(ys.len()); + let (Some(&x0), Some(&y0)) = (xs.first(), ys.first()) else { return l }; + if n == 0 { + return l; + } + for v in l.iter_mut().take(x0 as usize) { + *v = y0 as i32; + } + for i in 0..n - 1 { + let (xa, xb, ya, yb) = (xs[i] as i32, xs[i + 1] as i32, ys[i] as i32, ys[i + 1] as i32); + let dx = xb - xa; + if dx <= 0 { + continue; + } + let delta = (yb - ya) * ((65536 + (dx >> 1)) / dx); + for x in 0..dx { + if let Some(v) = l.get_mut((xa + x) as usize) { + *v = ya + ((x * delta + 32768) >> 16); + } + } + } + let (xl, yl) = (xs[n - 1], ys[n - 1]); + for v in l.iter_mut().skip(xl as usize) { + *v = yl as i32; + } + l +} + fn round2(x: i32, n: u32) -> i32 { if n == 0 { x } else { (x + (1 << (n - 1))) >> n } } @@ -124,23 +157,7 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader } else { (g.num_cr_points, &g.point_cr_value, &g.point_cr_scaling) }; - if n == 0 { - continue; - } - for v in l.iter_mut().take(xs[0] as usize) { - *v = ys[0] as i32; - } - for i in 0..n - 1 { - let dy = ys[i + 1] as i32 - ys[i] as i32; - let dx = xs[i + 1] as i32 - xs[i] as i32; - let delta = dy * ((65536 + (dx >> 1)) / dx); - for x in 0..dx { - l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16); - } - } - for v in l.iter_mut().skip(xs[n - 1] as usize) { - *v = ys[n - 1] as i32; - } + *l = scaling_lut(n, xs, ys); } let scale_lut = |plane: usize, index: i32| -> i32 { let sh = bd - 8; @@ -274,3 +291,19 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaling_lut_interpolates_and_tolerates_duplicate_points() { + let l = scaling_lut(2, &[0, 255], &[0, 255]); + assert_eq!((l[0], l[128], l[255]), (0, 128, 255)); + // Duplicate and descending x values don't divide by zero. + let l = scaling_lut(4, &[10, 10, 5, 200], &[20, 40, 60, 80]); + assert_eq!(l[0], 20); + assert_eq!(l[255], 80); + assert_eq!(scaling_lut(0, &[], &[]), [0; 256]); + } +} diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index fa9db0a6..16cccd9e 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -1256,6 +1256,7 @@ impl FrameHeader { g.point_y_value[i] = r.f(8)? as u8; g.point_y_scaling[i] = r.f(8)? as u8; } + check_increasing(&g.point_y_value[..g.num_y_points], "point_y_value")?; g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? }; if seq.color.mono_chrome || g.chroma_scaling_from_luma @@ -1272,6 +1273,7 @@ impl FrameHeader { g.point_cb_value[i] = r.f(8)? as u8; g.point_cb_scaling[i] = r.f(8)? as u8; } + check_increasing(&g.point_cb_value[..g.num_cb_points], "point_cb_value")?; g.num_cr_points = r.f(4)? as usize; if g.num_cr_points > 10 { return Err(Error::Invalid("num_cr_points")); @@ -1280,6 +1282,7 @@ impl FrameHeader { g.point_cr_value[i] = r.f(8)? as u8; g.point_cr_scaling[i] = r.f(8)? as u8; } + check_increasing(&g.point_cr_value[..g.num_cr_points], "point_cr_value")?; } g.grain_scaling_minus_8 = r.f(2)?; g.ar_coeff_lag = r.f(2)?; @@ -1367,3 +1370,23 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result { } } } + +/// Film-grain scaling point values must strictly increase (AV1 spec 6.8.20); a duplicate would +/// divide by zero when the scaling lookup is built. +fn check_increasing(values: &[u8], what: &'static str) -> Result<()> { + if values.windows(2).all(|w| w[0] < w[1]) { Ok(()) } else { Err(Error::Invalid(what)) } +} + +#[cfg(test)] +mod grain_point_tests { + use super::*; + + #[test] + fn scaling_points_must_strictly_increase() { + assert!(check_increasing(&[], "p").is_ok()); + assert!(check_increasing(&[7], "p").is_ok()); + assert!(check_increasing(&[0, 10, 255], "p").is_ok()); + assert!(matches!(check_increasing(&[0, 10, 10], "point_y_value"), Err(Error::Invalid("point_y_value")))); + assert!(check_increasing(&[20, 10], "p").is_err()); + } +} diff --git a/crates/bitstream/src/budget.rs b/crates/bitstream/src/budget.rs new file mode 100644 index 00000000..cf98df2c --- /dev/null +++ b/crates/bitstream/src/budget.rs @@ -0,0 +1,82 @@ +//! Memory budget for decoded pictures, shared by the video decoders. +//! +//! A per-axis size cap does not bound memory: a decoder holds the current picture, every +//! reference picture and the pictures still being decoded on worker threads. Each decoder checks +//! the whole picture store against [`DECODE_MEMORY_BUDGET`] with checked arithmetic before it +//! allocates for a new picture size, so a hostile header is an error, not an out-of-memory abort. + +/// Bytes the decoded pictures of one decoder may take: 4 GiB on 64-bit targets, 1 GiB on 32-bit +/// ones (wasm32 has a 4 GiB address space in all). +pub const DECODE_MEMORY_BUDGET: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + +/// The shape of one decoded picture as a decoder allocates it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PictureShape { + /// Allocated luma samples per row and rows (padding included). + pub width: u64, + pub height: u64, + /// Chroma subsampling shifts (0 or 1); ignored when `chroma_planes` is 0. + pub shift_x: u32, + pub shift_y: u32, + /// Chroma planes (0 for monochrome, 2 otherwise). + pub chroma_planes: u64, + /// Bytes per sample (1 for 8-bit buffers, 2 for 16-bit ones). + pub bytes_per_sample: u64, +} + +impl PictureShape { + /// Bytes of one picture, `None` if it does not fit in a `u64`. + pub fn bytes(&self) -> Option { + let luma = self.width.checked_mul(self.height)?; + let cw = self.width.div_ceil(1 << self.shift_x.min(1)); + let ch = self.height.div_ceil(1 << self.shift_y.min(1)); + let chroma = cw.checked_mul(ch)?.checked_mul(self.chroma_planes)?; + luma.checked_add(chroma)?.checked_mul(self.bytes_per_sample) + } + + /// Bytes of `pictures` such pictures, `None` on overflow. + pub fn store_bytes(&self, pictures: u64) -> Option { + self.bytes()?.checked_mul(pictures) + } + + /// Whether `pictures` such pictures fit in `budget` bytes. + pub fn fits(&self, pictures: u64, budget: u64) -> bool { + self.store_bytes(pictures).is_some_and(|b| b <= budget) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shape(w: u64, h: u64) -> PictureShape { + PictureShape { width: w, height: h, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 2 } + } + + #[test] + fn bytes_count_luma_and_subsampled_chroma() { + assert_eq!(shape(4, 4).bytes(), Some((16 + 2 * 4) * 2)); + // Odd sizes round chroma up. + assert_eq!(shape(3, 3).bytes(), Some((9 + 2 * 4) * 2)); + let mono = PictureShape { chroma_planes: 0, bytes_per_sample: 1, ..shape(10, 10) }; + assert_eq!(mono.bytes(), Some(100)); + let full = PictureShape { shift_x: 0, shift_y: 0, ..shape(10, 10) }; + assert_eq!(full.bytes(), Some(600)); + } + + #[test] + fn exact_budget_boundary() { + let s = shape(4, 4); // 48 bytes + assert!(s.fits(10, 480)); + assert!(!s.fits(10, 479)); + assert!(!s.fits(11, 480)); + } + + #[test] + fn overflow_is_never_within_budget() { + let huge = shape(u64::MAX / 2, 3); + assert_eq!(huge.bytes(), None); + assert!(!huge.fits(1, u64::MAX)); + assert!(!shape(1 << 31, 1 << 31).fits(u64::MAX, u64::MAX)); + } +} diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 48884673..a4f7d407 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -3,18 +3,23 @@ //! - [`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. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] use std::fmt; +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 { @@ -22,6 +27,7 @@ impl fmt::Display for BitError { 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"), } } } @@ -140,10 +146,11 @@ impl<'a> BitReader<'a> { self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb) } - /// Signed Exp-Golomb `se(v)`. + /// 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 { let k = self.read_ue()? as i64; - Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) }) + 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`. @@ -202,7 +209,10 @@ impl BitWriter { self.write_bits(b as u32, 1); } pub fn write_ue(&mut self, v: u32) { - let x = v as u64 + 1; + 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 { @@ -215,9 +225,11 @@ impl BitWriter { 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 k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 }; - self.write_ue(k); + 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 @@ -310,12 +322,15 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { out } -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units. +/// 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> { + if !(1..=4).contains(&length_size) { + return Err(BitError::InvalidLengthSize); + } let mut out = Vec::new(); let mut i = 0; while i < data.len() { - if i + length_size > data.len() { + if length_size > data.len() - i { return Err(BitError::Eof); } let mut len = 0usize; @@ -323,7 +338,7 @@ pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result data.len() { + if len > data.len() - i { return Err(BitError::Eof); } out.push(&data[i..i + len]); @@ -361,6 +376,35 @@ mod tests { 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]; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1f4dbfcd..778664a7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,12 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } + +[dev-dependencies] +deckcraft-anim = { workspace = true } +zip = { workspace = true } + +# The browser clock for comment timestamps (wasm has no std::time::SystemTime). +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" + diff --git a/crates/engine/data/thesaurus.txt b/crates/engine/data/thesaurus.txt new file mode 100644 index 00000000..1916eb46 --- /dev/null +++ b/crates/engine/data/thesaurus.txt @@ -0,0 +1,264 @@ +# DeckCraft's built-in thesaurus: headword|synonym, synonym, ... +# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line; +# a headword may appear on several lines (one per sense). +ability|capability, capacity, skill, competence, talent +able|capable, competent, qualified, skilled +accept|agree to, approve, adopt, embrace +accurate|precise, exact, correct, right +achieve|accomplish, attain, reach, realize, deliver +achievement|accomplishment, success, attainment, milestone +add|include, append, attach, insert +advantage|benefit, edge, strength, asset, upside +affect|influence, impact, change, alter +aim|goal, objective, target, purpose, intent +allow|permit, enable, let, authorize +analyze|examine, study, assess, evaluate, review +answer|reply, response, solution +approach|method, strategy, way, technique, plan +area|region, field, domain, zone, sector +arrange|organize, order, sort, plan +ask|request, inquire, query, question +assess|evaluate, gauge, judge, review, appraise +assist|help, aid, support, back +attempt|try, effort, endeavor, bid +bad|poor, weak, inferior, unfavorable, negative +basic|fundamental, essential, core, elementary, simple +begin|start, commence, launch, initiate, open +benefit|advantage, gain, value, payoff, upside +best|finest, top, leading, optimal, premier +big|large, major, substantial, significant, sizable +boost|increase, raise, lift, improve, strengthen +brief|short, concise, succinct, quick +bright|vivid, brilliant, luminous, clever +build|construct, create, develop, assemble, establish +business|company, firm, enterprise, organization, venture +buy|purchase, acquire, obtain +careful|cautious, thorough, meticulous, attentive +cause|reason, source, origin, driver +change|alter, modify, adjust, revise, transform +change|shift, transition, adjustment, revision +cheap|inexpensive, affordable, low-cost, economical +check|verify, confirm, examine, inspect, review +choice|option, alternative, selection, pick +choose|select, pick, opt for, decide on +clear|plain, obvious, evident, transparent, lucid +client|customer, buyer, account, patron +close|near, nearby, adjacent +close|end, finish, conclude, shut +common|frequent, usual, typical, widespread, shared +company|business, firm, organization, enterprise, corporation +complete|finish, conclude, finalize, accomplish +complete|whole, full, entire, total +complex|complicated, intricate, involved, elaborate +concern|worry, issue, problem, risk +consider|weigh, contemplate, evaluate, think about +continue|proceed, carry on, keep going, persist, maintain +cost|price, expense, charge, outlay +create|make, build, produce, design, generate +critical|crucial, vital, essential, key, decisive +current|present, existing, ongoing, latest, up-to-date +customer|client, buyer, consumer, user +data|information, figures, facts, statistics +decide|determine, choose, resolve, settle +decrease|reduce, lower, cut, decline, drop +define|specify, describe, explain, outline +deliver|provide, supply, bring, hand over, ship +demonstrate|show, prove, illustrate, display +describe|explain, outline, portray, depict +design|plan, layout, blueprint, pattern +design|create, plan, devise, draft +develop|build, create, grow, expand, evolve +difference|distinction, contrast, gap, variation +different|distinct, various, diverse, unlike +difficult|hard, challenging, tough, demanding +discuss|talk about, debate, examine, review +easy|simple, straightforward, effortless, painless +effect|result, impact, outcome, consequence +effective|successful, efficient, productive, powerful +effort|work, attempt, endeavor, exertion +end|finish, conclusion, close, completion +enough|sufficient, adequate, ample +ensure|guarantee, make sure, secure, confirm +error|mistake, fault, flaw, bug, slip +essential|vital, crucial, key, necessary, fundamental +establish|set up, found, create, institute +estimate|forecast, projection, approximation, guess +evaluate|assess, appraise, judge, rate, review +event|occasion, function, happening, incident +example|instance, case, illustration, sample +excellent|outstanding, superb, exceptional, first-rate +expand|grow, extend, enlarge, broaden, scale +expect|anticipate, foresee, predict, await +explain|clarify, describe, illustrate, account for +fail|fall short, miss, flop, break down +fast|quick, rapid, swift, speedy +feature|characteristic, attribute, capability, function +final|last, closing, concluding, ultimate +find|discover, locate, identify, uncover +finish|complete, end, conclude, wrap up +focus|concentrate, center, target, emphasize +focus|emphasis, priority, attention, core +future|upcoming, coming, forthcoming, prospective +gain|increase, rise, improvement, profit +get|obtain, acquire, receive, gain +give|provide, offer, supply, grant +goal|aim, objective, target, purpose, ambition +good|fine, great, strong, solid, positive +great|excellent, outstanding, terrific, remarkable +grow|increase, expand, rise, develop, scale +growth|expansion, increase, development, rise +help|assist, aid, support, enable +help|assistance, aid, support, guidance +high|elevated, tall, top, strong +idea|concept, notion, thought, proposal +identify|recognize, pinpoint, detect, determine +impact|effect, influence, consequence, result +important|significant, key, major, crucial, vital +improve|enhance, upgrade, refine, strengthen, boost +include|contain, cover, incorporate, comprise +increase|raise, boost, grow, expand, lift +increase|rise, growth, gain, jump +information|data, details, facts, knowledge +innovative|inventive, creative, original, novel, pioneering +issue|problem, concern, matter, question +job|task, assignment, role, position +keep|retain, maintain, preserve, hold +key|main, central, critical, essential, principal +large|big, sizable, substantial, major, extensive +last|final, latest, previous, closing +launch|start, introduce, roll out, release, unveil +lead|guide, direct, head, steer, run +learn|discover, find out, master, study +less|fewer, smaller, reduced, lower +little|small, minor, slight, modest +look|appear, seem +look|view, glance, examine, inspect +low|minimal, modest, small, reduced +main|primary, chief, principal, central, key +maintain|keep, preserve, sustain, uphold +major|significant, important, key, principal, big +make|create, build, produce, generate +manage|run, handle, oversee, direct, lead +market|industry, sector, segment, marketplace +measure|gauge, assess, quantify, track +meet|satisfy, fulfil, reach, achieve +meeting|session, conference, discussion, review +method|approach, technique, process, procedure, way +model|framework, template, pattern, design +more|additional, extra, further, added +move|shift, transfer, relocate, transition +need|requirement, demand, necessity +need|require, call for, demand, want +new|fresh, recent, novel, modern, latest +next|following, upcoming, subsequent +number|figure, count, total, quantity, amount +objective|goal, aim, target, purpose +offer|provide, propose, extend, present +old|previous, former, legacy, outdated, existing +open|start, launch, begin, unveil +opportunity|opening, chance, prospect, possibility +option|choice, alternative, possibility, route +organize|arrange, structure, coordinate, order +outcome|result, consequence, effect, end result +overview|summary, outline, synopsis, recap +part|portion, piece, segment, section, component +partner|ally, collaborator, associate, affiliate +people|staff, team, individuals, personnel +plan|strategy, scheme, roadmap, proposal, program +plan|organize, arrange, schedule, prepare +point|aspect, detail, issue, argument +popular|well-liked, widespread, in demand, favored +possible|feasible, achievable, viable, potential +present|show, display, introduce, demonstrate +present|current, existing, ongoing +price|cost, rate, charge, fee +priority|focus, concern, precedence, goal +problem|issue, challenge, difficulty, obstacle +process|procedure, method, workflow, system +produce|make, create, generate, manufacture, yield +product|offering, item, solution, good +profit|earnings, return, gain, margin +progress|advance, headway, development, improvement +project|initiative, program, undertaking, venture +propose|suggest, recommend, put forward, offer +provide|give, supply, deliver, offer +purpose|aim, goal, objective, intent, reason +quality|standard, caliber, excellence, grade +question|query, issue, inquiry +quick|fast, rapid, speedy, swift, prompt +rapid|fast, quick, swift, speedy +reach|achieve, attain, hit, arrive at +reason|cause, rationale, motive, basis +recent|latest, new, current, fresh +recommend|suggest, advise, propose, advocate +reduce|cut, lower, decrease, lessen, trim +region|area, territory, zone, market +relevant|pertinent, applicable, related, appropriate +remove|delete, eliminate, take out, drop +replace|substitute, swap, supersede, change +report|account, summary, review, statement +require|need, demand, call for, necessitate +research|study, investigation, analysis, inquiry +resource|asset, supply, means, capacity +result|outcome, effect, consequence, finding +review|assessment, evaluation, examination, analysis +review|examine, assess, evaluate, check, revisit +revenue|income, sales, earnings, turnover +risk|danger, threat, exposure, hazard +role|part, function, position, responsibility +run|operate, manage, conduct, execute +safe|secure, protected, reliable +same|identical, equal, matching, equivalent +save|keep, store, preserve, conserve +see|view, observe, notice, spot +sell|market, offer, trade, retail +service|support, assistance, offering +share|portion, part, stake, percentage +share|distribute, spread, communicate, pass on +show|demonstrate, display, present, reveal, illustrate +significant|important, notable, considerable, major, meaningful +similar|alike, comparable, related, equivalent +simple|easy, straightforward, plain, basic +size|scale, extent, magnitude, dimensions +small|little, minor, compact, modest +solution|answer, fix, remedy, resolution +solve|resolve, fix, settle, work out +specific|particular, precise, exact, distinct +start|begin, launch, commence, kick off, initiate +step|stage, phase, measure, action +strategy|plan, approach, policy, roadmap, tactic +strong|robust, solid, powerful, sturdy +structure|framework, organization, layout, arrangement +success|achievement, accomplishment, win, triumph +successful|effective, thriving, prosperous, winning +suggest|propose, recommend, advise, imply +summary|overview, recap, synopsis, digest +support|help, back, assist, sustain +support|help, assistance, backing, aid +sure|certain, confident, positive, definite +target|goal, objective, aim +task|job, assignment, duty, activity +team|group, crew, staff, squad, unit +test|trial, check, experiment, evaluation +test|try, check, evaluate, verify +think|believe, consider, reckon, suppose +time|period, duration, while, moment +tool|instrument, utility, device, aid +total|sum, whole, aggregate, overall +trend|pattern, tendency, movement, direction +understand|grasp, comprehend, follow, realize +unique|distinctive, singular, one-of-a-kind, unmatched +update|revise, refresh, upgrade, modernize +update|revision, change, upgrade, news +use|employ, apply, utilize, leverage +useful|helpful, practical, handy, valuable +value|worth, benefit, merit, importance +various|diverse, assorted, different, several +view|opinion, perspective, outlook, position +vision|outlook, aspiration, goal, dream +way|method, approach, manner, means +weak|feeble, fragile, poor, limited +well|effectively, successfully, nicely, properly +work|labor, effort, activity, job +work|function, operate, run, succeed +year|annum, twelve months diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 8b6fe5a7..c3e27804 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { has_selection, add ), - cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove), + cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove), cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim), cmd!( "animation.timing", @@ -82,6 +82,51 @@ fn start_of(s: &str) -> AnimStart { } } +/// The effect option `given` as one of `allowed`. Besides the stored codes ("b", "lt", "u", …) +/// it takes the direction words people type: "fromBottom", "bottom", "fromTopLeft", +/// "upRight". `None` when it matches none of them: the renderer reads direction codes letter +/// by letter, so an unchecked "fromBottom" would fly in from the right and the bottom. +fn resolve_option(allowed: &[&str], given: &str) -> Option { + if allowed.is_empty() || allowed.contains(&given) { + return Some(given.to_string()); + } + let low = given.to_ascii_lowercase(); + if let Some(o) = allowed.iter().find(|o| o.to_ascii_lowercase() == low) { + return Some(o.to_string()); + } + let mut rest = low.strip_prefix("from").unwrap_or(&low).trim_start_matches(['-', '_', ' ']); + // Direction words in order, each as both its edge letter and its arrow letter. + let mut dirs: Vec<(char, char)> = Vec::new(); + while !rest.is_empty() { + let words: [(&str, (char, char)); 8] = [ + ("bottom", ('b', 'd')), + ("down", ('b', 'd')), + ("top", ('t', 'u')), + ("up", ('t', 'u')), + ("left", ('l', 'l')), + ("right", ('r', 'r')), + ("-", ('-', '-')), + ("_", ('-', '-')), + ]; + let (w, d) = words.iter().find(|(w, _)| rest.starts_with(w))?; + if d.0 != '-' { + dirs.push(*d); + } + rest = &rest[w.len()..]; + } + if dirs.is_empty() || dirs.len() > 2 { + return None; + } + let mut cands: Vec = Vec::new(); + for pick in [|d: &(char, char)| d.0, |d: &(char, char)| d.1] { + let code: String = dirs.iter().map(pick).collect(); + let rev: String = code.chars().rev().collect(); + cands.push(code); + cands.push(rev); + } + cands.into_iter().find(|c| allowed.contains(&c.as_str())) +} + fn build(p: &Value) -> Result { let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; let class = class_of(str_param(p, "class")); @@ -93,7 +138,12 @@ fn build(p: &Value) -> Result { let mut a = Animation { class: info.2, effect: info.0.to_string(), - option: str_param(p, "option").map(String::from).or_else(|| info.5.first().map(|o| o.to_string())).unwrap_or_default(), + option: match str_param(p, "option") { + Some(o) => { + resolve_option(info.5, o).ok_or_else(|| bad("animation", format!("option `{o}` is not one of {:?} for `{}`", info.5, info.0)))? + } + None => info.5.first().map(|o| o.to_string()).unwrap_or_default(), + }, duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4), delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0), preset_id: Some(info.3), @@ -127,7 +177,7 @@ pub fn default_path(effect: &str, option: &str) -> String { "turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75), "shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(), "loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(), - _ => format!("M 0 0 L {dx} {dy} E"), + other => deckcraft_model::anim::preset_path(other).unwrap_or_else(|| format!("M 0 0 L {dx} {dy} E")), } } @@ -270,9 +320,14 @@ fn options(s: &mut Session, p: &Value) -> Result { }); each_target(s, p, |a| { if let Some(o) = &option { - a.option = o.clone(); - if a.class == AnimClass::Path { - a.path = Some(default_path(&a.effect, o)); + // Each target takes the option in its own effect's terms; one it has no such + // option for keeps its own. + let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]); + if let Some(o) = resolve_option(allowed, o) { + if a.class == AnimClass::Path { + a.path = Some(default_path(&a.effect, &o)); + } + a.option = o; } } if let Some(b) = build_mode { @@ -305,7 +360,7 @@ fn get(s: &mut Session, p: &Value) -> Result { fn painter_pick(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = id_param(p, "id") + let id = id_param(p, "id", "animation.painter")? .or_else(|| st.selection.shapes.first().copied()) .ok_or_else(|| bad("animation.painter", "select an animated shape first"))?; let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?; @@ -355,6 +410,26 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use super::resolve_option; + + #[test] + fn direction_words_resolve_to_codes() { + let fly = &["b", "lb", "l", "lt", "t", "rt", "r", "rb"][..]; + assert_eq!(resolve_option(fly, "fromBottom").as_deref(), Some("b")); + assert_eq!(resolve_option(fly, "bottom").as_deref(), Some("b")); + assert_eq!(resolve_option(fly, "fromTopLeft").as_deref(), Some("lt")); + assert_eq!(resolve_option(fly, "fromBottomRight").as_deref(), Some("rb")); + assert_eq!(resolve_option(fly, "lt").as_deref(), Some("lt")); + assert_eq!(resolve_option(fly, "sideways"), None); + assert_eq!(resolve_option(fly, "fromBottomTopLeft"), None); + let wipe = &["r", "l", "u", "d", "ru", "lu", "rd", "ld"][..]; + assert_eq!(resolve_option(wipe, "fromBottom").as_deref(), Some("d")); + assert_eq!(resolve_option(wipe, "upRight").as_deref(), Some("ru")); + let split = &["horzIn", "horzOut", "vertIn", "vertOut"][..]; + assert_eq!(resolve_option(split, "HorzIn").as_deref(), Some("horzIn")); + assert_eq!(resolve_option(&[], "anything").as_deref(), Some("anything")); + } + use super::*; #[test] diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0c91bbaa..75cb6fcb 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -9,6 +9,18 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "guide.add", + "Add Guide", + ["View", "Show", "Guides"], + None, + "{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none", + has_doc, + guide_add + ), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move), + cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete), + cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list), cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), @@ -129,7 +141,7 @@ fn order_list(list: &mut Vec, ids: &[ShapeId], how: i32) { /// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop. /// A shape stays in the list that holds it, so a group's child stays in its group. fn reorder(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; + let id = id_param(p, "id", "arrange.reorder")?.ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?; let st = s.doc()?; let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) { @@ -153,7 +165,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result { } /// Bounds of shapes (rotation included). -fn union_bounds(xs: &[Xfrm]) -> Option { +pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option { let mut it = xs.iter().map(Xfrm::bounds); let first = it.next()?; Some(it.fold(first, |a, b| a.union(b))) @@ -177,7 +189,7 @@ fn group(s: &mut Session, p: &Value) -> Result { let xs: Vec = members.iter().filter_map(|m| m.xfrm).collect(); let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?; let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height()); - let id = ShapeId(doc.alloc_id()); + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("arrange.group", NO_ID_SPACE))?); let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0); let g = Shape { id, @@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// Guides are kept on the slide; a file can hold at most this many. +const MAX_GUIDES: usize = 200; + +fn guide_limit(s: &Session, vertical: bool) -> Result { + let size = s.doc()?.doc.slide_size; + Ok(if vertical { size.width } else { size.height }) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "defaults", false) { + if !s.doc()?.doc.guides.is_empty() { + return Ok(json!({"added": 0})); + } + let size = s.doc()?.doc.slide_size; + return s.edit(|doc, _| { + doc.guides = vec![ + deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 }, + deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 }, + ]; + Ok(json!({"added": 2})) + }); + } + let vertical = bool_or(p, "vertical", true); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit); + if s.doc()?.doc.guides.len() >= MAX_GUIDES { + return Err(bad("guide.add", "too many guides")); + } + s.edit(|doc, _| { + doc.guides.push(deckcraft_model::DrawGuide { vertical, pos }); + Ok(json!({"index": doc.guides.len() - 1})) + }) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit); + s.edit(|doc, _| { + if let Some(g) = doc.guides.get_mut(i) { + g.pos = pos; + } + Ok(json!({"index": i, "pos": pos})) + }) +} + +fn guide_delete(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.delete")?; + s.edit(|doc, _| { + if i < doc.guides.len() { + doc.guides.remove(i); + } + ok() + }) +} + +fn guide_list(s: &mut Session, _p: &Value) -> Result { + Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default()) +} + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/engine/src/cmd/artistic.rs b/crates/engine/src/cmd/artistic.rs new file mode 100644 index 00000000..071cd383 --- /dev/null +++ b/crates/engine/src/cmd/artistic.rs @@ -0,0 +1,202 @@ +//! Picture Format ▸ Artistic Effects: a picture redrawn as a pencil sketch, blurred, grainy… +//! +//! The effect is applied to the pixels and the result becomes the picture, as PowerPoint does; +//! the original is kept (`PictureFill::artistic`) so the effect can be changed or removed, and +//! both are saved (`a14:imgLayer`). The effects approximate PowerPoint's look. + +use deckcraft_model::style::Artistic; +use deckcraft_model::{MediaId, ShapeId, ShapeKind}; +use image::{GrayImage, Rgba, RgbaImage}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +/// The effects offered: `a14` element name and label. +pub const EFFECTS: &[(&str, &str)] = &[ + ("artisticBlur", "Blur"), + ("artisticPencilGrayscale", "Pencil Grayscale"), + ("artisticLineDrawing", "Line Drawing"), + ("artisticFilmGrain", "Film Grain"), + ("artisticCutout", "Cutout"), + ("artisticGlowEdges", "Glow Edges"), + ("artisticPhotocopy", "Photocopy"), +]; + +pub fn specs() -> Vec { + vec![cmd!( + "picture.artisticEffect", + "Artistic Effects", + ["Picture Format", "Adjust"], + None, + "{effect: none|artisticBlur|artisticPencilGrayscale|artisticLineDrawing|artisticFilmGrain|artisticCutout|artisticGlowEdges|artisticPhotocopy, ids?}", + has_selection, + artistic_effect + )] +} + +/// Largest side processed; bigger pictures are scaled down first. +const MAX_SIDE: u32 = 3000; + +fn luma(p: &Rgba) -> f32 { + 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 +} + +fn gray(img: &RgbaImage) -> GrayImage { + GrayImage::from_fn(img.width(), img.height(), |x, y| image::Luma([luma(img.get_pixel(x, y)).round().clamp(0.0, 255.0) as u8])) +} + +/// Sobel edge strength, 0–255. +fn edges(g: &GrayImage) -> GrayImage { + let (w, h) = g.dimensions(); + let at = |x: i64, y: i64| g.get_pixel(x.clamp(0, w as i64 - 1) as u32, y.clamp(0, h as i64 - 1) as u32)[0] as f32; + GrayImage::from_fn(w, h, |x, y| { + let (x, y) = (x as i64, y as i64); + let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x - 1, y) - at(x - 1, y + 1); + let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x, y - 1) - at(x + 1, y - 1); + image::Luma([((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 255.0) as u8]) + }) +} + +/// Applies `effect` to `img` (alpha kept). +pub fn apply(effect: &str, img: &RgbaImage) -> Option { + let (w, h) = img.dimensions(); + let scale = ((w * w + h * h) as f32).sqrt() / 1000.0; + let alpha = |x: u32, y: u32| img.get_pixel(x, y)[3]; + let out = match effect { + "artisticBlur" => image::imageops::blur(img, (6.0 * scale).max(1.0)), + "artisticPencilGrayscale" | "artisticLineDrawing" | "artisticPhotocopy" => { + let soft = image::imageops::blur(&gray(img), (0.8 * scale).max(0.5)); + let e = edges(&soft); + RgbaImage::from_fn(w, h, |x, y| { + let ev = e.get_pixel(x, y)[0] as f32; + let gv = soft.get_pixel(x, y)[0] as f32; + let v = match effect { + "artisticPencilGrayscale" => 255.0 - ev * 2.0 - (255.0 - gv) * 0.25, + "artisticLineDrawing" => { + if ev > 28.0 { + 255.0 - (ev - 28.0) * 3.0 + } else { + 255.0 + } + } + _ => { + if ev > 40.0 || gv < 70.0 { + 0.0 + } else { + 255.0 + } + } + }; + let v = v.clamp(0.0, 255.0) as u8; + Rgba([v, v, v, alpha(x, y)]) + }) + } + "artisticFilmGrain" => { + // Deterministic noise, so the same picture gives the same grain. + let mut seed: u32 = 0x9E37_79B9; + RgbaImage::from_fn(w, h, |x, y| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let n = (seed % 61) as f32 - 30.0; + let p = img.get_pixel(x, y); + let c = |v: u8| ((v as f32 - 128.0) * 1.1 + 128.0 + n).clamp(0.0, 255.0) as u8; + Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]]) + }) + } + "artisticCutout" => { + let soft = image::imageops::blur(img, (1.5 * scale).max(0.5)); + RgbaImage::from_fn(w, h, |x, y| { + let p = soft.get_pixel(x, y); + let q = |v: u8| ((v as f32 / 255.0 * 3.0).round() / 3.0 * 255.0) as u8; + Rgba([q(p[0]), q(p[1]), q(p[2]), alpha(x, y)]) + }) + } + "artisticGlowEdges" => { + let e = edges(&gray(img)); + RgbaImage::from_fn(w, h, |x, y| { + let k = (e.get_pixel(x, y)[0] as f32 / 255.0 * 2.5).min(1.0); + let p = img.get_pixel(x, y); + let c = |v: u8| (v as f32 * k + 255.0 * k * 0.3).clamp(0.0, 255.0) as u8; + Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]]) + }) + } + _ => return None, + }; + Some(out) +} + +/// Decodes `data`, applies `effect` and encodes a PNG. +fn process(effect: &str, data: &[u8]) -> Result> { + let img = image::load_from_memory(data).map_err(|_| bad("picture.artisticEffect", "this picture's format can't be processed"))?; + let img = if img.width().max(img.height()) > MAX_SIDE { img.thumbnail(MAX_SIDE, MAX_SIDE) } else { img }; + let out = apply(effect, &img.to_rgba8()).ok_or_else(|| bad("picture.artisticEffect", "unknown effect"))?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(out) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|_| bad("picture.artisticEffect", "the processed picture couldn't be stored"))?; + Ok(png) +} + +fn artistic_effect(s: &mut Session, p: &Value) -> Result { + let effect = match str_param(p, "effect") { + Some("none") => None, + Some(e) if EFFECTS.iter().any(|(n, _)| *n == e) => Some(e.to_string()), + _ => return Err(bad("picture.artisticEffect", "`effect` is none or one of artisticBlur, artisticPencilGrayscale, …")), + }; + let ids = targets(s, p)?; + let st = s.doc()?; + // (shape, original picture, its bytes and name) + let mut work: Vec<(ShapeId, MediaId, Option<(Vec, String)>)> = Vec::new(); + for id in ids { + let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { continue }; + let original = fill.artistic.as_ref().map_or(fill.media, |a| a.original); + let item = st.doc.media(original).map(|m| (m.data.to_vec(), m.name.clone())); + work.push((id, original, item)); + } + if work.is_empty() { + return Err(bad("picture.artisticEffect", "select a picture")); + } + let mut results: Vec<(ShapeId, MediaId, Option<(String, Vec)>)> = Vec::new(); + for (id, original, item) in work { + let processed = match (&effect, item) { + (None, _) => None, + (Some(e), Some((data, name))) => { + Some((format!("{}-{}.png", name.rsplit_once('.').map_or(name.as_str(), |x| x.0), e), process(e, &data)?)) + } + (Some(_), None) => return Err(bad("picture.artisticEffect", "the picture's data is missing")), + }; + results.push((id, original, processed)); + } + let effect2 = effect.clone(); + s.edit(move |doc, sel| { + let mut set: Vec<(ShapeId, MediaId, Option)> = Vec::new(); + for (id, original, processed) in &results { + let m = match processed { + Some((name, png)) => Some(doc.add_media(name, "image/png", png.clone()).ok_or_else(|| bad("picture.artisticEffect", NO_ID_SPACE))?), + None => None, + }; + set.push((*id, *original, m)); + } + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.artisticEffect", "no slide"))?; + for (id, original, m) in set { + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) + && let ShapeKind::Picture { fill } = &mut sh.kind + { + match (m, &effect2) { + (Some(m), Some(e)) => { + fill.media = m; + fill.artistic = Some(Artistic { effect: e.clone(), original }); + } + _ => { + fill.media = original; + fill.artistic = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({"effect": effect})) +} diff --git a/crates/engine/src/cmd/background.rs b/crates/engine/src/cmd/background.rs new file mode 100644 index 00000000..5ad93ad9 --- /dev/null +++ b/crates/engine/src/cmd/background.rs @@ -0,0 +1,244 @@ +//! Picture Format ▸ Remove Background: a classical matte, no model. The background is what the +//! picture's edges reach through similar colours: a flood fill from every edge pixel, each step +//! close to the pixel before it (gradients and soft shadows) and to the edge colour it started +//! from (so it can't leak into the subject). It turns transparent, with a one-pixel soft edge. + +use std::collections::VecDeque; + +use deckcraft_model::{ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +/// Larger pictures are refused rather than matted slowly (16 megapixels). +const MAX_PIXELS: u64 = 16_000_000; + +pub fn specs() -> Vec { + vec![cmd!( + "picture.removeBackground", + "Remove Background", + ["Picture Format", "Adjust"], + None, + "{tolerance?: 0..100 (how different a colour may be from the edge's, default 20), ids?}", + has_selection, + remove_background + )] +} + +fn dist(a: [u8; 4], b: [u8; 4]) -> f64 { + let d = |i: usize| f64::from(a[i]) - f64::from(b[i]); + (d(0) * d(0) + d(1) * d(1) + d(2) * d(2)).sqrt() +} + +/// Make the background of `img` transparent. `tolerance` is 0–100. Returns how many pixels +/// became transparent. +pub fn matte(img: &mut image::RgbaImage, tolerance: f64) -> usize { + let (w, h) = (img.width() as usize, img.height() as usize); + if w == 0 || h == 0 { + return 0; + } + // Distances run 0–441 (black to white); the step limit follows gradients, the seed limit + // keeps the fill on the edge's colour. + let seed_tol = tolerance.clamp(0.0, 100.0) * 4.41; + let step_tol = (seed_tol * 0.35).max(6.0); + let px = |img: &image::RgbaImage, i: usize| img.get_pixel((i % w) as u32, (i / w) as u32).0; + // 0: subject, 1: background; the seed colour each background pixel was reached from. + let mut bg = vec![false; w * h]; + let mut seed = vec![[0u8; 4]; w * h]; + let mut queue = VecDeque::new(); + let push_edge = |i: usize, bg: &mut [bool], seed: &mut [[u8; 4]], q: &mut VecDeque| { + if let Some(b) = bg.get_mut(i) + && !*b + { + *b = true; + if let Some(s) = seed.get_mut(i) { + *s = px(img, i); + } + q.push_back(i); + } + }; + for x in 0..w { + push_edge(x, &mut bg, &mut seed, &mut queue); + push_edge((h - 1) * w + x, &mut bg, &mut seed, &mut queue); + } + for y in 0..h { + push_edge(y * w, &mut bg, &mut seed, &mut queue); + push_edge(y * w + w - 1, &mut bg, &mut seed, &mut queue); + } + while let Some(i) = queue.pop_front() { + let (x, y) = (i % w, i / w); + let here = px(img, i); + let from = seed.get(i).copied().unwrap_or(here); + let mut near = [None; 4]; + if x > 0 { + near[0] = Some(i - 1); + } + if x + 1 < w { + near[1] = Some(i + 1); + } + if y > 0 { + near[2] = Some(i - w); + } + if y + 1 < h { + near[3] = Some(i + w); + } + for j in near.into_iter().flatten() { + if bg.get(j).copied().unwrap_or(true) { + continue; + } + let c = px(img, j); + // Transparent pixels are background already. + if c[3] < 16 || (dist(c, here) <= step_tol && dist(c, from) <= seed_tol) { + if let Some(b) = bg.get_mut(j) { + *b = true; + } + if let Some(s) = seed.get_mut(j) { + *s = from; + } + queue.push_back(j); + } + } + } + // A background edge that is the subject's colour throughout (the subject touches the + // edge on every side) is no background: leave such a picture alone. + let n = bg.iter().filter(|b| **b).count(); + if n == w * h { + return 0; + } + let is_bg = |x: usize, y: usize| bg.get(y * w + x).copied().unwrap_or(false); + let mut soft = vec![]; + for y in 0..h { + for x in 0..w { + if is_bg(x, y) { + continue; + } + let touching = + (x > 0 && is_bg(x - 1, y)) || (x + 1 < w && is_bg(x + 1, y)) || (y > 0 && is_bg(x, y - 1)) || (y + 1 < h && is_bg(x, y + 1)); + if touching { + soft.push((x, y)); + } + } + } + for (i, b) in bg.iter().enumerate() { + if *b { + img.get_pixel_mut((i % w) as u32, (i / w) as u32).0[3] = 0; + } + } + for (x, y) in soft { + let p = img.get_pixel_mut(x as u32, y as u32); + p.0[3] = (u16::from(p.0[3]) * 2 / 3) as u8; + } + n +} + +fn remove_background(s: &mut Session, p: &Value) -> Result { + let tolerance = f64_or(p, "tolerance", 20.0); + let ids = targets(s, p)?; + let mut done: Vec<(ShapeId, String, Vec)> = vec![]; + { + let st = s.doc()?; + for id in &ids { + let Some(sh) = st.shape(*id) else { continue }; + let ShapeKind::Picture { fill } = &sh.kind else { continue }; + let Some(item) = st.doc.media(fill.media) else { continue }; + let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(item.data.as_slice())).with_guessed_format() else { continue }; + let Ok((iw, ih)) = reader.into_dimensions() else { continue }; + if u64::from(iw) * u64::from(ih) > MAX_PIXELS { + return Err(bad("picture.removeBackground", format!("{iw}×{ih} pixels is too large; compress the picture first"))); + } + let Ok(dynimg) = image::load_from_memory(&item.data) else { continue }; + let mut img = dynimg.to_rgba8(); + if matte(&mut img, tolerance) == 0 { + continue; + } + let mut png = vec![]; + if image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + continue; + } + let stem = item.name.rsplit_once('.').map_or(item.name.as_str(), |(a, _)| a); + done.push((*id, format!("{stem}-no-background.png"), png)); + } + } + if done.is_empty() { + return Err(bad("picture.removeBackground", "no background found: select a picture whose edges show its background")); + } + let n = done.len(); + s.edit(|doc, sel| { + let added: Vec<(ShapeId, deckcraft_model::MediaId)> = done + .into_iter() + .map(|(id, name, png)| Ok((id, doc.add_media(&name, "image/png", png).ok_or_else(|| bad("picture.removeBackground", NO_ID_SPACE))?))) + .collect::>()?; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.removeBackground", "no slide"))?; + for (id, media) in added { + if let Some(sh) = deckcraft_model::find_shape_mut(shapes, id) + && let ShapeKind::Picture { fill } = &mut sh.kind + { + fill.media = media; + } + } + Ok(()) + })?; + Ok(json!({"pictures": n})) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A red disc on a white-to-grey gradient. + fn disc() -> image::RgbaImage { + image::RgbaImage::from_fn(80, 60, |x, y| { + let (dx, dy) = (f64::from(x) - 40.0, f64::from(y) - 30.0); + if dx * dx + dy * dy < 15.0 * 15.0 { + image::Rgba([220, 30, 30, 255]) + } else { + let g = 255 - (x / 2) as u8; + image::Rgba([g, g, g, 255]) + } + }) + } + + #[test] + fn matte_clears_the_gradient_and_keeps_the_subject() { + let mut img = disc(); + let n = matte(&mut img, 20.0); + assert!(n > 80 * 60 / 2, "{n}"); + assert_eq!(img.get_pixel(0, 0).0[3], 0); + assert_eq!(img.get_pixel(79, 59).0[3], 0, "the darker end of the gradient"); + assert_eq!(img.get_pixel(40, 30).0, [220, 30, 30, 255]); + // The disc's rim is softened, not cut. + let rim = img.get_pixel(40, 16).0[3]; + assert!(rim > 0 && rim < 255 || img.get_pixel(40, 15).0[3] < 255, "{rim}"); + // A picture with nothing but subject at its edges is left alone. + let mut flat = image::RgbaImage::from_pixel(10, 10, image::Rgba([10, 200, 10, 255])); + assert_eq!(matte(&mut flat, 20.0), 0); + assert!(flat.pixels().all(|p| p.0[3] == 255)); + } + + #[test] + fn remove_background_replaces_the_picture_and_undoes() { + let mut png = vec![]; + image::DynamicImage::ImageRgba8(disc()).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + let r = s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "disc.png"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let media_of = |s: &Session| match &s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().kind { + ShapeKind::Picture { fill } => fill.media, + _ => panic!("not a picture"), + }; + let before = media_of(&s); + s.execute("picture.removeBackground", &json!({"ids": [id]})).unwrap(); + let after = media_of(&s); + assert_ne!(before, after); + let item = s.doc().unwrap().doc.media(after).unwrap().clone(); + assert_eq!(item.name, "disc-no-background.png"); + let img = image::load_from_memory(&item.data).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(0, 0).0[3], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(media_of(&s), before); + // Not a picture: refused. + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + assert!(s.execute("picture.removeBackground", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/color_at_tests.rs b/crates/engine/src/cmd/color_at_tests.rs new file mode 100644 index 00000000..77c8d475 --- /dev/null +++ b/crates/engine/src/cmd/color_at_tests.rs @@ -0,0 +1,73 @@ +//! `slide.colorAt`, the colour the slide is drawn with at a point: what the Eyedropper takes (#367). + +use serde_json::{Value, json}; + +use crate::*; + +fn deck() -> Session { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + s +} + +fn color_at(s: &mut Session, x: f64, y: f64) -> Value { + s.execute("slide.colorAt", &json!({"x": x, "y": y})).unwrap() +} + +#[test] +fn a_point_in_a_shape_gives_its_fill_and_one_beside_it_the_background() { + let mut s = deck(); + let inside = color_at(&mut s, 150.0, 150.0); + assert_eq!(inside["color"], "FF0000"); + assert_eq!(inside["alpha"], 255); + let beside = color_at(&mut s, 700.0, 450.0); + assert_ne!(beside["color"], "FF0000"); + // Just inside and just outside the shape's corner. + assert_eq!(color_at(&mut s, 101.0, 101.0)["color"], "FF0000"); + assert_eq!(color_at(&mut s, 99.0, 99.0)["color"], beside["color"]); + // A finer or coarser picture gives the same colour. + for scale in [0.5, 1.0, 4.0] { + let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale})).unwrap(); + assert_eq!(v["color"], "FF0000", "scale {scale}"); + } + // It is a query: the deck is not changed and nothing goes on the undo stack. + let revision = s.doc().unwrap().revision; + color_at(&mut s, 150.0, 150.0); + assert_eq!(s.doc().unwrap().revision, revision); +} + +#[test] +fn a_see_through_fill_gives_the_colour_it_is_drawn_with() { + let mut s = deck(); + let background = color_at(&mut s, 700.0, 450.0)["color"].as_str().unwrap().to_string(); + s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap(); + let v = color_at(&mut s, 150.0, 150.0); + let c = v["color"].as_str().unwrap(); + assert_ne!(c, "FF0000", "half of the background shows through"); + assert_ne!(c, background); + assert_eq!(v["alpha"], 255, "the slide is opaque"); +} + +#[test] +fn points_outside_the_slide_and_bad_parameters_are_errors() { + let mut s = deck(); + for p in [ + json!({"x": -1, "y": 10}), + json!({"x": 10, "y": -0.5}), + json!({"x": 1e9, "y": 10}), + json!({"x": 10, "y": 1e9}), + json!({"x": 10}), + json!({"y": 10}), + json!({"x": "10", "y": 10}), + json!({"x": 10, "y": 10, "slide": 99}), + ] { + assert!(s.execute("slide.colorAt", &p).is_err(), "{p}"); + } + // Odd scales are clamped, not refused or crashed on. + for scale in [0.0, -3.0, 1e9] { + let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale})); + assert!(v.is_ok(), "{scale}: {v:?}"); + } +} diff --git a/crates/engine/src/cmd/compress.rs b/crates/engine/src/cmd/compress.rs new file mode 100644 index 00000000..180391c5 --- /dev/null +++ b/crates/engine/src/cmd/compress.rs @@ -0,0 +1,325 @@ +//! Picture Format ▸ Adjust ▸ Compress Pictures: downsample pictures to a target resolution for +//! the size they are shown at, and optionally delete their cropped-away areas. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::style::PictureFill; +use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "picture.compress", + "Compress Pictures", + ["Picture Format", "Adjust"], + None, + "{ppi?: 330|220|150|96 (default 220), deleteCropped?: bool (default true), all?: bool (every picture in the presentation; default the selected pictures), ids?} → {pictures, before, after}", + has_doc, + compress + )] +} + +/// A picture compressed for one shape: new bytes, content type and the crop it now needs. +struct Packed { + bytes: Vec, + content_type: &'static str, + crop: [f64; 4], +} + +/// Whether `crop` cuts something away (and only cuts: negative values pad, and are kept). +fn real_crop(c: &[f64; 4]) -> bool { + c.iter().all(|v| (0.0..1.0).contains(v)) && c.iter().any(|v| *v > 0.0) && c[0] + c[2] < 1.0 && c[1] + c[3] < 1.0 +} + +/// Largest picture decoded for compression, in pixels (a 61 MP camera frame fits); bigger ones +/// are left as they are. +const MAX_PIXELS: u64 = 64_000_000; +/// Most memory the decoder may allocate for one picture. +const MAX_DECODE_BYTES: u64 = 512 << 20; + +/// Decode `data` within [`MAX_PIXELS`] and [`MAX_DECODE_BYTES`]: the size is read from the header +/// first, so a picture claiming to be huge is never decoded. +fn decode(data: &[u8], format: image::ImageFormat) -> Option { + let (w, h) = image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions().ok()?; + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(w); + limits.max_image_height = Some(h); + limits.max_alloc = Some(MAX_DECODE_BYTES); + reader.limits(limits); + reader.decode().ok() +} + +/// Compress `data` for a picture shown `w_pt` × `h_pt` with `crop`, at `ppi`. `None` when the +/// format isn't a raster we re-encode (SVG, EMF, GIF…) or nothing would get smaller. +fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_cropped: bool) -> Option { + use image::ImageFormat as F; + let format = image::guess_format(data).ok()?; + if !matches!(format, F::Png | F::Jpeg | F::Bmp | F::Tiff | F::WebP) { + return None; + } + let mut img = decode(data, format)?; + let (iw, ih) = (img.width() as f64, img.height() as f64); + if iw < 1.0 || ih < 1.0 { + return None; + } + let cut = delete_cropped && real_crop(&crop); + let mut new_crop = crop; + // The share of the image that is visible across and down. + let (mut fx, mut fy) = ((1.0 - crop[0] - crop[2]).max(0.01), (1.0 - crop[1] - crop[3]).max(0.01)); + if cut { + let x = (crop[0] * iw).floor() as u32; + let y = (crop[1] * ih).floor() as u32; + let w = ((fx * iw).round() as u32).clamp(1, img.width() - x.min(img.width() - 1)); + let h = ((fy * ih).round() as u32).clamp(1, img.height() - y.min(img.height() - 1)); + img = img.crop_imm(x, y, w, h); + new_crop = [0.0; 4]; + (fx, fy) = (1.0, 1.0); + } else if !crop.iter().all(|v| v.is_finite()) { + return None; + } + // Pixels needed for the whole (possibly still cropped) image at `ppi`. + let need_w = (w_pt.abs() / 72.0 * ppi / fx).ceil().max(1.0); + let need_h = (h_pt.abs() / 72.0 * ppi / fy).ceil().max(1.0); + let k = (need_w / img.width() as f64).max(need_h / img.height() as f64); + let resized = k < 1.0; + if resized { + let w = ((img.width() as f64 * k).round() as u32).max(1); + let h = ((img.height() as f64 * k).round() as u32).max(1); + img = img.resize_exact(w, h, image::imageops::FilterType::Lanczos3); + } + if !resized && !cut { + return None; + } + let alpha = img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] < 255); + let mut out = std::io::Cursor::new(Vec::new()); + let content_type = if format == F::Jpeg && !alpha { + let rgb = img.to_rgb8(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85).encode_image(&rgb).ok()?; + "image/jpeg" + } else { + img.write_to(&mut out, F::Png).ok()?; + "image/png" + }; + let bytes = out.into_inner(); + // Never make a picture bigger just to resample it; deleting a crop is worth it regardless. + if bytes.len() >= data.len() && !cut { + return None; + } + Some(Packed { bytes, content_type, crop: new_crop }) +} + +/// Picture shapes in `shapes` (groups included) that `want` accepts. +fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) { + out.push(sh); + } else if let Some(ch) = sh.children_mut() { + pictures_mut(ch, want, out, depth + 1); + } + } +} + +fn compress(s: &mut Session, p: &Value) -> Result { + let ppi = f64_param(p, "ppi").unwrap_or(220.0); + if !ppi.is_finite() || !(36.0..=1200.0).contains(&ppi) { + return Err(bad("picture.compress", "`ppi` must be between 36 and 1200 (PowerPoint offers 330, 220, 150 and 96)")); + } + let delete_cropped = bool_or(p, "deleteCropped", true); + let all = bool_or(p, "all", false); + let ids: HashSet = if all { HashSet::new() } else { targets(s, p)?.into_iter().collect() }; + if !all && ids.is_empty() { + return Err(bad("picture.compress", "select a picture, or pass all: true")); + } + let current = s.doc()?.selection.slide; + s.edit(|doc, _| { + let want = |id: ShapeId| all || ids.contains(&id); + let mut before = 0usize; + let mut after = 0usize; + let mut count = 0usize; + let mut changed: Vec = vec![]; + // The same picture at the same size and crop compresses once. + let mut cache: HashMap<(u32, [u64; 4], u64, u64), Option<(MediaId, [f64; 4], usize)>> = HashMap::new(); + let range: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + for i in range { + let Some(slide) = doc.slides.get(i).cloned() else { continue }; + let mut slide = slide.as_ref().clone(); + let mut pics = vec![]; + pictures_mut(&mut slide.shapes, &want, &mut pics, 0); + let mut touched = false; + for sh in pics { + let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0)); + let ShapeKind::Picture { fill } = &mut sh.kind else { continue }; + let PictureFill { media, crop, .. } = fill.clone(); + let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits()); + let hit = match cache.get(&key) { + Some(v) => *v, + None => { + let packed = doc.media(media).filter(|m| !m.data.is_empty()).and_then(|m| { + let old = m.data.len(); + pack(&m.data, crop, w, h, ppi, delete_cropped).map(|pk| (pk, old, m.name.clone())) + }); + let v = match packed { + Some((pk, old, name)) => { + before += old; + after += pk.bytes.len(); + let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or(name); + let ext = if pk.content_type == "image/jpeg" { "jpg" } else { "png" }; + let id = doc + .add_media(&format!("{stem}.{ext}"), pk.content_type, pk.bytes) + .ok_or_else(|| bad("picture.compress", NO_ID_SPACE))?; + Some((id, pk.crop, old)) + } + None => None, + }; + cache.insert(key, v); + v + } + }; + if let Some((id, new_crop, _)) = hit { + changed.push(media); + fill.media = id; + fill.crop = new_crop; + count += 1; + touched = true; + } + } + if touched && let Some(sl) = doc.slides.get_mut(i) { + *sl = Arc::new(slide); + } + } + // Drop the originals nothing uses any more, so the saved file actually shrinks. + if !changed.is_empty() { + // Typed walk of every reference (artistic originals, fills, posters, sounds, fonts…). + let used: HashSet = crate::media_refs::presentation_media(doc).into_iter().collect(); + doc.media.retain(|m| !changed.contains(&m.id) || used.contains(&m.id)); + } + Ok(json!({"pictures": count, "before": before, "after": after, "ppi": ppi})) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png(w: u32, h: u32) -> Vec { + let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 7 % 256) as u8, (y * 5 % 256) as u8, ((x ^ y) % 256) as u8])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + } + + #[test] + fn pack_downsamples_to_the_shown_size() { + // 2000 px shown at 1 inch: 150 ppi needs 150 px. + let pk = pack(&png(2000, 1000), [0.0; 4], 72.0, 36.0, 150.0, true).unwrap(); + let img = image::load_from_memory(&pk.bytes).unwrap(); + assert_eq!((img.width(), img.height()), (150, 75)); + assert_eq!(pk.content_type, "image/png"); + // Already small enough: nothing to do. + assert!(pack(&png(100, 50), [0.0; 4], 72.0, 36.0, 150.0, true).is_none()); + // Not a raster we re-encode. + assert!(pack(b"", [0.0; 4], 72.0, 72.0, 150.0, true).is_none()); + } + + #[test] + fn pack_deletes_cropped_areas_or_keeps_them() { + let src = png(400, 400); + // Left half cropped away, shown 2 in wide at 100 ppi → 200 px of the visible half. + let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 144.0, 288.0, 100.0, true).unwrap(); + assert_eq!(pk.crop, [0.0; 4]); + let img = image::load_from_memory(&pk.bytes).unwrap(); + assert_eq!((img.width(), img.height()), (200, 400)); + // Keeping the crop: the whole image stays, scaled so the visible half has its pixels. + let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 72.0, 72.0, 100.0, false).unwrap(); + assert_eq!(pk.crop, [0.5, 0.0, 0.0, 0.0]); + let img = image::load_from_memory(&pk.bytes).unwrap(); + assert_eq!(img.width(), 200); + } + + /// A PNG whose header claims `w` × `h` (no pixel data): enough for the size check. + fn png_header(w: u32, h: u32) -> Vec { + fn crc(bytes: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for b in bytes { + c ^= u32::from(*b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + fn chunk(out: &mut Vec, kind: &[u8], data: &[u8]) { + let body: Vec = kind.iter().chain(data).copied().collect(); + out.extend((data.len() as u32).to_be_bytes()); + out.extend(&body); + out.extend(crc(&body).to_be_bytes()); + } + let mut ihdr = w.to_be_bytes().to_vec(); + ihdr.extend(h.to_be_bytes()); + ihdr.extend([8, 2, 0, 0, 0]); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &[]); + chunk(&mut out, b"IEND", &[]); + out + } + + #[test] + fn huge_pictures_are_not_decoded() { + // 72 MP: over the cap, though within what the decoder would allocate by default. + let data = png_header(9_000, 8_000); + assert_eq!( + image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Png).into_dimensions().ok(), + Some((9_000, 8_000)) + ); + assert!(decode(&data, image::ImageFormat::Png).is_none()); + assert!(pack(&data, [0.0; 4], 72.0, 72.0, 96.0, true).is_none()); + } + + /// Compressing a picture whose media is also another picture's artistic-effect original must + /// keep that media: the effect needs it to be changed or removed. + #[test] + fn media_still_used_as_an_artistic_original_is_kept() { + use deckcraft_model::style::Artistic; + let mut s = Session::with_new(); + let big = png(1200, 600); + let processed = png(1100, 600); + s.edit(|doc, _| { + let original = doc.add_media("original.png", "image/png", big.clone()).expect("media"); + let shown = doc.add_media("processed.png", "image/png", processed.clone()).expect("media"); + let slide = Arc::make_mut(doc.slides.first_mut().ok_or_else(|| bad("test", "no slide"))?); + let xfrm = Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 36.0)); + let with_effect = + PictureFill { media: shown, artistic: Some(Artistic { effect: "artisticBlur".into(), original }), ..Default::default() }; + slide.shapes.push(Shape { id: ShapeId(70_001), xfrm, kind: ShapeKind::Picture { fill: with_effect }, ..Default::default() }); + slide.shapes.push(Shape { + id: ShapeId(70_002), + xfrm, + kind: ShapeKind::Picture { fill: PictureFill { media: original, ..Default::default() } }, + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + let r = s.execute("picture.compress", &json!({"all": true, "ppi": 96})).unwrap(); + assert_eq!(r["pictures"], 2, "{r}"); + let doc = &s.doc().unwrap().doc; + // Every reference left in the deck resolves to media that is still there. + for id in crate::media_refs::presentation_media(doc) { + assert!(doc.media(id).is_some(), "{id:?} was deleted while still referenced"); + } + assert!(doc.media.iter().any(|m| m.data.as_slice() == big.as_slice()), "the artistic original was deleted"); + assert!(!doc.media.iter().any(|m| m.data.as_slice() == processed.as_slice()), "the replaced processed picture is dropped"); + } +} diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..03468f37 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use deckcraft_geom::Size; use deckcraft_model::text::{Run, RunKind, TextBody}; -use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builtin_themes}; +use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, builtin_themes}; use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults}; use serde_json::{Value, json}; @@ -15,15 +15,35 @@ use crate::{Result, Session, Target}; pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), - cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes and font schemes", always, themes), - cmd!("design.colors", "Colors", ["Design", "Variants"], None, "{name} | {colors: {accent1: #RRGGBB, …}}", has_doc, colors), - cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts), + cmd!( + "design.browseTheme", + "Browse for Themes…", + ["Design", "Themes"], + None, + "{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file", + has_doc, + browse_theme + ), + cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes, font schemes and effect schemes", always, themes), + cmd!( + "design.colors", + "Colors", + ["Design", "Variants"], + None, + "{} opens Create New Theme Colors | {name} | {colors: {dk1|lt1|dk2|lt2|accent1..6|hlink|folHlink: #RRGGBB}}", + has_doc, + colors + ), + cmd!("design.variant", "Variants", ["Design", "Variants"], None, "{index: 0..3} (see design.variants)", has_doc, variant), + cmd!(query "design.variants", "Variant Gallery", [], None, "{} → the current theme's four colour variants and which is applied", has_doc, variants), + cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{} opens Create New Theme Fonts | {name} | {major, minor}", has_doc, fonts), + cmd!("design.effects", "Effects", ["Design", "Variants"], None, "{name} (see design.themes)", has_doc, effects), cmd!( "design.background", "Format Background", ["Design", "Customize"], None, - "{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}", + "{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}", has_slide, background ), @@ -33,7 +53,7 @@ pub fn specs() -> Vec { "Slide Size", ["Design", "Customize"], None, - "{preset?: widescreen|standard|… | w, h: pt, scale?: maximize|ensureFit|none}", + "{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}", has_doc, slide_size ), @@ -47,6 +67,42 @@ pub fn specs() -> Vec { header_footer ), cmd!(noundo "view.slideMaster", "Slide Master", ["View", "Master Views"], None, "{master?: index, layout?: index}", has_doc, master_view), + cmd!( + "view.handoutMaster", + "Handout Master", + ["View", "Master Views"], + None, + "{} (creates the handout master with header, date, footer and page number if the deck has none)", + has_doc, + handout_view + ), + cmd!( + "handout.placeholders", + "Handout Placeholders", + ["Handout Master", "Placeholders"], + None, + "{header?: bool, date?: bool, footer?: bool, pageNumber?: bool} (show or hide each)", + in_handout, + handout_placeholders + ), + cmd!( + "view.notesMaster", + "Notes Master", + ["View", "Master Views"], + None, + "{} (creates the notes master with header, date, slide image, notes body, footer and page number if the deck has none)", + has_doc, + notes_view + ), + cmd!( + "notes.placeholders", + "Notes Placeholders", + ["Notes Master", "Placeholders"], + None, + "{header?, date?, slideImage?, body?, footer?, pageNumber?: bool} (show or hide each)", + in_notes_master, + notes_placeholders + ), cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master), cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout), cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout), @@ -60,6 +116,16 @@ pub fn specs() -> Vec { has_doc, insert_placeholder ), + cmd!("master.preserve", "Preserve", ["Slide Master", "Edit Master"], None, "{preserve?: bool}", has_doc, preserve), + cmd!( + "master.placeholders", + "Layout Placeholders", + ["Slide Master", "Edit Master"], + None, + "{title?: bool, footers?: bool}", + has_doc, + placeholders + ), ] } @@ -79,10 +145,74 @@ fn theme(s: &mut Session, p: &Value) -> Result { Ok(json!({"theme": name})) } +fn browse_theme(s: &mut Session, p: &Value) -> Result { + let bytes = if let Some(data) = str_param(p, "data") { + super::base64_decode(data).ok_or_else(|| bad("design.browseTheme", "invalid base64 `data`"))? + } else { + #[cfg(not(target_arch = "wasm32"))] + { + let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?; + crate::io::read_file(path).map_err(crate::EngineError::Other)? + } + #[cfg(target_arch = "wasm32")] + return Err(bad("design.browseTheme", "give `data`")); + }; + if !deckcraft_pptx::sniff(&bytes) { + return Err(bad("design.browseTheme", "not a theme or PowerPoint file")); + } + let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?; + let name = theme.name.clone(); + s.edit(|doc, sel| { + // The theme's pictures (format-scheme blip fills) get ids of this deck. + let mut map = std::collections::HashMap::new(); + for m in &media { + let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("design.browseTheme", NO_ID_SPACE))?; + map.insert(m.id.0, id.0); + } + let theme = if map.is_empty() { theme.clone() } else { remap_media_ids(&theme, &map)? }; + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.browseTheme", "no master"))?); + m.theme = theme; + m.color_map = vec![]; + Ok(()) + })?; + Ok(json!({"theme": name})) +} + +/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills). +fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap) -> Result { + fn rec(v: &mut Value, map: &std::collections::HashMap, depth: usize) { + if depth > 64 { + return; + } + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + if k == "media" + && let Some(n) = x.as_u64().and_then(|n| u32::try_from(n).ok()) + && let Some(new) = map.get(&n) + { + *x = json!(new); + } else { + rec(x, map, depth + 1); + } + } + } + Value::Array(a) => a.iter_mut().for_each(|x| rec(x, map, depth + 1)), + _ => {} + } + } + let mut v = serde_json::to_value(theme).map_err(|e| bad("design.browseTheme", e.to_string()))?; + rec(&mut v, map, 0); + serde_json::from_value(v).map_err(|e| bad("design.browseTheme", e.to_string())) +} + fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { match sel.target { Target::Master { master } | Target::Layout { master, .. } => master, - Target::Slides => doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0), + Target::Slides | Target::Handout | Target::NotesMaster => { + doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0) + } } } @@ -91,123 +221,515 @@ fn themes(_s: &mut Session, _p: &Value) -> Result { "themes": builtin_themes().iter().map(|t| json!({"name": t.name, "major": t.fonts.major.latin, "minor": t.fonts.minor.latin, "colors": t.colors.colors.iter().map(|c| format!("#{}", c.hex())).collect::>()})).collect::>(), "colorSchemes": builtin_color_schemes().iter().map(|c| c.name.clone()).collect::>(), "fontSchemes": builtin_font_schemes().iter().map(|f| json!({"name": f.name, "major": f.major.latin, "minor": f.minor.latin})).collect::>(), + "effectSchemes": builtin_format_schemes().iter().map(|f| f.name.clone()).collect::>(), })) } fn colors(s: &mut Session, p: &Value) -> Result { - let scheme = if let Some(name) = str_param(p, "name") { - Some( - builtin_color_schemes() - .into_iter() - .find(|c| c.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.colors", format!("no colour scheme `{name}`")))?, - ) - } else { - None + 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 custom = p.get("colors").and_then(Value::as_object).cloned(); + let slots = parse_color_slots(colors_v)?; + let current = current_colors(s)?; + let next = next_scheme(¤t, 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"))?); - if let Some(sc) = &scheme { - m.theme.colors = sc.clone(); - } - if let Some(c) = &custom { - for (k, v) in c { - if let (Some(slot), Some(rgb)) = (deckcraft_color::SchemeSlot::from_xml(k), v.as_str().and_then(deckcraft_color::Rgba::from_hex)) { - m.theme.colors.set(slot, rgb); - } - } - m.theme.colors.name = "Custom".into(); - } + m.theme.colors = next; Ok(()) })?; ok() } +fn variant(s: &mut Session, p: &Value) -> Result { + 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})) +} + +fn variants(s: &mut Session, _p: &Value) -> Result { + 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::>()})).collect::>(), + })) +} + +fn current_colors(s: &Session) -> Result { + 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>> { + 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 { + 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) +} + fn fonts(s: &mut Session, p: &Value) -> Result { - let scheme = if let Some(name) = str_param(p, "name") { - builtin_font_schemes() - .into_iter() - .find(|f| f.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.fonts", format!("no font scheme `{name}`")))? - } else { - let major = str_param(p, "major").ok_or_else(|| bad("design.fonts", "give `name`, or `major` and `minor`"))?; - let minor = str_param(p, "minor").unwrap_or(major); - deckcraft_model::theme::FontScheme { - name: "Custom".into(), - major: deckcraft_model::theme::FontSet { latin: major.into(), ..Default::default() }, - minor: deckcraft_model::theme::FontSet { latin: minor.into(), ..Default::default() }, + 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(¤t, 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 = scheme; + 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 { + 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> { + 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 { + 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) +} + +fn effects(s: &mut Session, p: &Value) -> Result { + 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})) +} + fn background(s: &mut Session, p: &Value) -> Result { - let bg: Option = if bool_or(p, "reset", false) { - None - } else if let Some(c) = color_param(p, "color") { - Some(Background::Fill { fill: Fill::solid(c) }) - } else if let Some(i) = usize_param(p, "style") { + 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; - Some(Background::Ref { + 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 = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; g.rotate_with_shape = false; - Some(Background::Fill { fill: Fill::Gradient(g) }) + BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) } else if let Some(pt) = p.get("pattern") { - Some(Background::Fill { + 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) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; let ct = super::insert::content_type(&name, &bytes); - let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; - Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) }) + 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` or `reset`")); + 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| { - match sel.target { - Target::Master { master } => { - if let Some(m) = doc.masters.get_mut(master) { - Arc::make_mut(m).background = bg.clone(); + write_background(doc, sel, index, all, bg); + Ok(()) + })?; + ok() +} + +enum BgOp { + Set(Option), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + 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 backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> 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 = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)) + } + } +} + +fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { + 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 => { + let list: Vec = 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(); } } - 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.clone(); - } + } + } +} + +fn copy_slide_background(s: &mut Session) -> Result { + 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()); + if st.doc.slides.iter().all(|sl| sl.background == bg) { + 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) { + match bg { + Background::Fill { fill: Fill::Picture(pf) } => { + if let Some(pp) = props { + apply_picture_props(pf, pp); } - Target::Slides => { - let list: Vec = 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(); + 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); } } } } - Ok(()) - })?; - ok() + } } fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { @@ -251,6 +773,12 @@ fn slide_size(s: &mut Session, p: &Value) -> Result { 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; @@ -325,7 +853,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { }; let mut new_ids = vec![]; for _ in 0..3 { - new_ids.push(ShapeId(doc.alloc_id())); + 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); @@ -432,8 +960,14 @@ pub fn civil(z: i64) -> (i64, u32, u32) { fn master_view(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); - let layout = 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)))); + // 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 }, @@ -446,6 +980,198 @@ fn master_view(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(target).unwrap_or_default()) } +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)]; + +fn handout_view(s: &mut Session, _p: &Value) -> Result { + 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})) +} + +fn handout_placeholders(s: &mut Session, p: &Value) -> Result { + 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)})) +} + +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 + } + } +} + +fn notes_view(s: &mut Session, _p: &Value) -> Result { + 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})) +} + +fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = + NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; + 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)) +} + fn close_master(s: &mut Session, _p: &Value) -> Result { s.select(|_, sel| { sel.target = Target::Slides; @@ -459,8 +1185,8 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result { 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()); - let tid = ShapeId(doc.alloc_id()); + 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| { @@ -552,15 +1278,633 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result { text: Some(TextBody::default()), ..Default::default() }; - let id = super::insert::add_shape(s, shape, true)?; + let id = super::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; Ok(json!({"id": id})) } +fn preserve(s: &mut Session, p: &Value) -> Result { + 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})) +} + +fn placeholders(s: &mut Session, p: &Value) -> Result { + 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() + } +} + #[cfg(test)] mod tests { + 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 civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); } + + #[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"); + + 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); + + 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); + + 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"); + } +} + +#[cfg(test)] +mod browse_theme_tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + 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()); + } + + 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 = 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 { + 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)); + + 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); + + 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); + + 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); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..3b4346e1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,7 +1,9 @@ //! Edit menu: undo/redo, clipboard, duplicate, delete, select all. +use std::collections::HashMap; use std::sync::Arc; +use deckcraft_model::text::TextBody; use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId}; use serde_json::{Value, json}; @@ -12,13 +14,30 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy), - cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste), + cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy), + cmd!( + "edit.paste", + "Paste", + ["Home", "Clipboard"], + Some("Cmd+V"), + "{text?: plain text to paste, html?: rich text from another app, scope?: slides}", + has_doc, + paste + ), + cmd!( + "edit.pasteSpecial", + "Paste Special", + ["Home", "Clipboard"], + None, + "{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only", + has_doc, + paste_special + ), cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate), cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete), - cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_slide, select_all), + cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{scope?: slides}", has_slide, select_all), cmd!(noundo "edit.deselect", "Deselect", [], Some("Escape"), "{}", has_doc, deselect), cmd!(noundo "edit.select", "Select Objects", [], None, "{ids: [id], add?: bool, toggle?: bool}", has_slide, select), ] @@ -48,7 +67,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool { || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } -fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { deckcraft_model::walk(shapes, &mut |s, _| { match &s.kind { deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media), @@ -58,6 +77,12 @@ fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { out.push(p); } } + deckcraft_model::ShapeKind::Model3d { media, poster } => { + out.push(*media); + if let Some(p) = poster { + out.push(*p); + } + } _ => {} } if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill { @@ -87,6 +112,9 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { let mut mids = vec![]; for sl in &slides { media_of_shapes(&sl.shapes, &mut mids); + if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background { + mids.push(pf.media); + } } let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = slides.iter().map(|x| x.title()).collect::>().join("\n"); @@ -110,56 +138,169 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { media_of_shapes(&shapes, &mut mids); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); - s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() }; + let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone())); + s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } +fn copying_caret(s: &Session) -> bool { + s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) +} + fn copy(s: &mut Session, p: &Value) -> Result { + // A caret copies nothing; the previous clipboard must not leak into this answer. + let caret = copying_caret(s); let t = copy_inner(s, p)?; - Ok(json!({"text": t})) + let html = if caret { String::new() } else { copied_html(s) }; + Ok(json!({"text": t, "html": html})) +} + +/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes). +fn copied_html(s: &Session) -> String { + if let Some(body) = &s.clipboard.text { + return richclip::body_to_html(body); + } + let mut paragraphs = Vec::new(); + for sh in &s.clipboard.shapes { + if let Some(t) = &sh.text { + paragraphs.extend(t.paragraphs.clone()); + } + } + if paragraphs.is_empty() { + return String::new(); + } + richclip::body_to_html(&TextBody { paragraphs, ..Default::default() }) } fn cut(s: &mut Session, p: &Value) -> Result { + let caret = copying_caret(s); let t = copy_inner(s, p)?; + let html = if caret { String::new() } else { copied_html(s) }; delete(s, p)?; - Ok(json!({"text": t})) + Ok(json!({"text": t, "html": html})) } -/// Give pasted shapes fresh ids (groups included) and map connector ends. -pub(crate) fn reid(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) { - fn rec(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) { +/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends +/// attached to a shape that was copied too follow its copy; ends attached to a shape outside the +/// copied set are detached (the connector keeps its geometry), so a copy never stays glued to +/// the original or to an unrelated shape that happens to share an id in another deck. +pub(crate) fn reid(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) -> Result> { + fn rec( + cmd: &str, + doc: &mut deckcraft_model::Presentation, + shapes: &mut [Shape], + map: &mut HashMap, + depth: usize, + ) -> Result<()> { + if depth > 64 { + return Ok(()); + } + for s in shapes { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + map.insert(s.id, id); + s.id = id; + if let Some(ch) = s.children_mut() { + rec(cmd, doc, ch, map, depth + 1)?; + } + } + Ok(()) + } + let mut map = HashMap::new(); + rec(cmd, doc, shapes, &mut map, 0)?; + walk_mut(shapes, &mut |s| { + if let deckcraft_model::ShapeKind::Connector { start, end } = &mut s.kind { + for e in [start, end] { + *e = e.and_then(|(id, site)| map.get(&id).map(|n| (*n, site))); + } + } + }); + Ok(map) +} + +/// Point copied animations at the copies: targets and triggers in `map` follow it; an animation +/// whose target wasn't copied is dropped, and a trigger that wasn't copied is cleared. +pub(crate) fn remap_animations(anims: &mut Vec, map: &HashMap) { + anims.retain_mut(|a| { + let Some(n) = map.get(&a.shape) else { return false }; + a.shape = *n; + a.trigger = a.trigger.and_then(|t| map.get(&t).copied()); + true + }); +} + +/// Visit every shape mutably, depth-first (groups included, depth-bounded like `walk`). +fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) { + fn rec(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape), depth: usize) { if depth > 64 { return; } for s in shapes { - s.id = ShapeId(doc.alloc_id()); + f(s); if let Some(ch) = s.children_mut() { - rec(doc, ch, depth + 1); + rec(ch, f, depth + 1); } } } - rec(doc, shapes, 0); + rec(shapes, f, 0); } -fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) { +/// Rewrite the media ids shapes use (`map`: clipboard id to destination id). +fn remap_media(shapes: &mut [Shape], map: &HashMap) { + if map.is_empty() { + return; + } + let m = |id: &mut MediaId| { + if let Some(n) = map.get(id) { + *id = *n; + } + }; + walk_mut(shapes, &mut |s| { + match &mut s.kind { + deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media), + deckcraft_model::ShapeKind::Media(c) => { + m(&mut c.media); + if let Some(p) = c.poster.as_mut() { + m(p); + } + } + deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p), + _ => {} + } + if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill { + m(&mut pf.media); + } + }); +} + +/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in +/// `doc` by other bytes (another deck) gets a fresh id, and the pasted shapes must use it. +fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) -> Result> { + let mut map = HashMap::new(); for m in media { - if doc.media(m.id).is_none_or(|x| x.data != m.data) { - if doc.media(m.id).is_none() { - doc.media.push(m.clone()); - } else { - // Id clash with different bytes: unusual (other deck); keep ours, add theirs as new. - let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); - let _ = id; + match doc.media(m.id) { + None => doc.media.push(m.clone()), + Some(x) if x.data == m.data => {} + Some(_) => { + let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?; + map.insert(m.id, id); } } } + Ok(map) } fn paste(s: &mut Session, p: &Value) -> Result { // External plain text (from the system clipboard) wins when given and differs from ours. + // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words. if let Some(t) = str_param(p, "text") && t != s.clipboard.plain { + if let Some(html) = str_param(p, "html") + && let Some(body) = richclip::html_to_body(html) + && richclip::same_words(&body.text(), t) + { + return paste_external_body(s, &body); + } return paste_plain(s, t); } let editing = s.doc()?.selection.text.is_some(); @@ -174,12 +315,19 @@ fn paste(s: &mut Session, p: &Value) -> Result { if !clip.slides.is_empty() { let at = s.doc()?.selection.slide + 1; return s.edit(|doc, sel| { - bring_media(doc, &clip.media); + let media = bring_media("edit.paste", doc, &clip.media)?; let mut new_ids = vec![]; for (k, sl) in clip.slides.iter().enumerate() { let mut sl = sl.clone(); - sl.id = SlideId(doc.alloc_id()); - reid(doc, &mut sl.shapes); + sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?); + let map = reid("edit.paste", doc, &mut sl.shapes)?; + remap_animations(&mut sl.animations, &map); + remap_media(&mut sl.shapes, &media); + if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background + && let Some(n) = media.get(&pf.media) + { + pf.media = *n; + } if doc.layout(sl.layout).is_none() { sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); } @@ -200,9 +348,10 @@ fn paste(s: &mut Session, p: &Value) -> Result { let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some())); let off = if same_slide { off } else { 0.0 }; return s.edit(|doc, sel| { - bring_media(doc, &clip.media); + let media = bring_media("edit.paste", doc, &clip.media)?; let mut shapes = clip.shapes.clone(); - reid(doc, &mut shapes); + reid("edit.paste", doc, &mut shapes)?; + remap_media(&mut shapes, &media); for sh in &mut shapes { if let Some(x) = sh.xfrm.as_mut() { x.x += off; @@ -228,6 +377,33 @@ fn paste(s: &mut Session, p: &Value) -> Result { ok() } +fn paste_external_body(s: &mut Session, body: &TextBody) -> Result { + if s.doc()?.selection.text.is_some() { + return super::text::paste_body(s, body); + } + let plain = body.text(); + let st = s.doc()?; + let size = st.doc.slide_size; + let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0]; + let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?; + let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?; + let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?; + let body = body.clone(); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?; + if let Some(t) = sh.text.as_mut() { + let (wrap, autofit) = (t.body.wrap, t.body.autofit); + *t = body.clone(); + t.body.wrap = wrap; + t.body.autofit = autofit; + } + sel.shapes = vec![ShapeId(id)]; + sel.text = None; + Ok(json!({"id": id})) + }) +} + fn paste_plain(s: &mut Session, text: &str) -> Result { if s.doc()?.selection.text.is_some() { return super::text::insert_text(s, text, None); @@ -239,6 +415,113 @@ fn paste_plain(s: &mut Session, text: &str) -> Result { s.execute("insert.textBox", &json!({"rect": rect, "text": text})) } +fn paste_special(s: &mut Session, p: &Value) -> Result { + match str_param(p, "as").unwrap_or("destinationTheme") { + "destinationTheme" | "useDestinationTheme" => paste(s, &json!({})), + "text" | "keepTextOnly" => paste_text(s, &json!({})), + "keepSource" | "keepSourceFormatting" => { + let saved = s.clipboard.clone(); + if let Some((scheme, fonts)) = &saved.source_theme { + bake_theme(&mut s.clipboard.shapes, scheme, fonts); + } + let r = paste(s, &json!({})); + let pastes = s.clipboard.pastes; + s.clipboard = saved; + s.clipboard.pastes = pastes; + r + } + "picture" => paste_picture(s), + other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))), + } +} + +/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the +/// shapes keep their look under another theme. +pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) { + fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) { + if depth > 256 { + return; + } + match v { + Value::Object(o) => { + if o.get("kind").and_then(Value::as_str) == Some("scheme") + && let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::(x.clone()).ok()) + && slot != deckcraft_color::SchemeSlot::PhClr + && let Ok(rgb) = serde_json::to_value(scheme.get(slot)) + { + o.clear(); + o.insert("kind".into(), json!("rgb")); + o.insert("rgb".into(), rgb); + return; + } + for x in o.values_mut() { + rec(x, scheme, fonts, depth + 1); + } + } + Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)), + Value::String(t) if t.starts_with('+') => { + let set = match t.get(1..3) { + Some("mj") => &fonts.major, + Some("mn") => &fonts.minor, + _ => return, + }; + let face = match t.get(3..) { + Some("-lt") => &set.latin, + Some("-ea") => &set.ea, + Some("-cs") => &set.cs, + _ => return, + }; + if !face.is_empty() { + *t = face.clone(); + } + } + _ => {} + } + } + for sh in shapes.iter_mut() { + let Ok(mut v) = serde_json::to_value(&*sh) else { continue }; + rec(&mut v, scheme, fonts, 0); + if let Ok(baked) = serde_json::from_value::(v) { + *sh = baked; + } + } +} + +/// Paste Special › Picture: the copied shapes drawn into one PNG picture. +fn paste_picture(s: &mut Session) -> Result { + let mut clip = s.clipboard.clone(); + if clip.shapes.is_empty() { + return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture")); + } + if let Some((scheme, fonts)) = &clip.source_theme { + bake_theme(&mut clip.shapes, scheme, fonts); + } + let st = s.doc()?; + let mut doc = (*st.doc).clone(); + bring_media("edit.pasteSpecial", &mut doc, &clip.media)?; + let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?; + let xs: Vec = clip.shapes.iter().filter_map(|x| x.xfrm).collect(); + let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?; + let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height()); + let gid = ShapeId(u32::MAX - 1); + let one = if clip.shapes.len() == 1 { + let mut sh = clip.shapes[0].clone(); + sh.id = gid; + sh + } else { + Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() } + }; + slide.shapes = vec![one]; + const SCALE: f64 = 2.0; + let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?; + // render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines). + let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE); + let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some()); + let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 }; + let png = img.to_png(); + s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]})) +} + fn paste_text(s: &mut Session, p: &Value) -> Result { let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone()); paste_plain(s, &t) @@ -251,7 +534,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let list = super::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); - reid(doc, &mut copies); + reid("edit.duplicate", doc, &mut copies)?; for (c, b) in copies.iter_mut().zip(boxes) { let mut x = b; x.x += off; @@ -323,7 +606,12 @@ fn count(v: &[Shape]) -> usize { n } -fn select_all(s: &mut Session, _p: &Value) -> Result { +fn select_all(s: &mut Session, p: &Value) -> Result { + match p.get("scope") { + None | Some(Value::Null) => {} + Some(Value::String(v)) if v == "slides" => return select_all_slides(s), + Some(_) => return Err(bad("edit.selectAll", "scope must be \"slides\"")), + } if s.doc()?.selection.text.is_some() { return s.execute("text.selectAll", &json!({})); } @@ -335,6 +623,22 @@ fn select_all(s: &mut Session, _p: &Value) -> Result { Ok(json!({"ids": ids})) } +/// Thumbnail-pane / sorter select all: every slide, hidden ones included. The current +/// slide stays where it is (the canvas must not jump), so this is not `slide.selectSlides`. +fn select_all_slides(s: &mut Session) -> Result { + if s.doc()?.selection.target != crate::Target::Slides { + return Err(bad("edit.selectAll", "the slide list is not shown while editing a master")); + } + let ids: Vec = s.doc()?.doc.slides.iter().map(|x| x.id).collect(); + s.select(|_, sel| { + sel.slides = ids.clone(); + sel.shapes.clear(); + sel.text = None; + sel.cells = None; + })?; + Ok(json!({"selected": ids})) +} + fn deselect(s: &mut Session, _p: &Value) -> Result { s.painter = None; s.anim_painter = None; @@ -353,7 +657,10 @@ fn deselect(s: &mut Session, _p: &Value) -> Result { } fn select(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => id_param(p, "id", "edit.select")?.map(|i| vec![i]).unwrap_or_default(), + }; let add = bool_or(p, "add", false); let toggle = bool_or(p, "toggle", false); let valid: Vec = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..4761dc10 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,14 +8,32 @@ use crate::{DocState, EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new), + cmd!( + noundo "file.new", + "New Presentation", + ["File"], + Some("Cmd+N"), + "{theme?: name, template?: starter name, size?: [w,h] pt, blank?: bool}", + always, + new + ), + cmd!(query "file.templates", "Starter Templates", [], None, "{} → [{name, summary, theme, slides}]", always, templates), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open), + cmd!( + noundo "file.adopt", + "Open", + [], + None, + "{path, token?} (a presentation the host read in the background and staged with Session::stage_loaded; without one, reads path like file.open)", + always, + adopt + ), cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: deckcraft|pptx}", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), + 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?, 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", @@ -26,6 +44,15 @@ pub fn specs() -> Vec { info ), cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), + cmd!( + query "slide.colorAt", + "Color At", + [], + None, + "{slide?: index, x, y (slide units), scale?: px per unit (2)} → {color: \"RRGGBB\", alpha: 0..255, x, y}: the colour drawn at a point of the slide (the Eyedropper)", + has_doc, + color_at + ), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), cmd!( "file.properties", @@ -39,7 +66,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save), cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list), cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open), - cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard), + cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard), cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert), cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window), ] @@ -49,35 +76,84 @@ fn theme_named(name: &str) -> Option { deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)) } -fn new(s: &mut Session, p: &Value) -> Result { - let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default(); - let size = p - .get("size") +fn slide_size(p: &Value) -> deckcraft_geom::Size { + p.get("size") .and_then(Value::as_array) .and_then(|a| Some(deckcraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0) - .unwrap_or(defaults::WIDE); - let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)); + .unwrap_or(defaults::WIDE) +} + +fn templates(_s: &mut Session, _p: &Value) -> Result { + Ok(json!({ + "templates": crate::templates::starters().iter().map(|t| json!({ + "name": t.name, + "summary": t.summary, + "theme": t.theme, + "slides": t.slides.len(), + })).collect::>(), + })) +} + +fn new(s: &mut Session, p: &Value) -> Result { + let template = str_param(p, "template").filter(|t| !t.is_empty()); + let theme_name = str_param(p, "theme").filter(|t| !t.is_empty()); + if template.is_some() && theme_name.is_some() { + return Err(bad("file.new", "pass `theme` or `template`, not both")); + } + if template.is_some() && bool_or(p, "blank", false) { + return Err(bad("file.new", "a template is not a blank presentation")); + } + let size = slide_size(p); + let (doc, template_name) = if let Some(name) = template { + let doc = crate::templates::build(name, size)?; + let chosen = crate::templates::find(name).map(|t| t.name); + (doc, chosen) + } else { + let theme = theme_name.and_then(theme_named).unwrap_or_default(); + (defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)), None) + }; + let slides = doc.slides.len(); let title = s.next_untitled(); let i = s.add_document(DocState::new(doc, None, title)); - Ok(json!({"document": i})) + let mut out = json!({"document": i, "slides": slides}); + if let Some(name) = template_name { + out["template"] = json!(name); + } + Ok(out) } /// Recognise and read presentation bytes (native, PPTX). pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { + open_checked(name, bytes).map(|(p, _)| p) +} + +/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged. +pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { if deckcraft_format::sniff(bytes) { - return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string())); + return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string())); } + open_other(name, bytes).map(|p| (p, vec![])) +} + +fn open_other(name: &str, bytes: &[u8]) -> Result { if deckcraft_pptx::sniff(bytes) { let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; deckcraft_format::repair(&mut p); return Ok(p); } + if deckcraft_pptx::is_encrypted(bytes) { + return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet."))); + } let lower = name.to_ascii_lowercase(); if lower.ends_with(".txt") || lower.ends_with(".md") { let text = String::from_utf8_lossy(bytes); let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - deckcraft_format::outline_to_slides(&mut p, &text); + if lower.ends_with(".md") { + deckcraft_format::markdown_to_slides(&mut p, &text); + } else { + deckcraft_format::outline_to_slides(&mut p, &text); + } if p.slides.is_empty() { p = defaults::new_presentation(None); } @@ -88,21 +164,66 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presentation) -> usize { let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); + crate::fonts::install_embedded_fonts(&p); let missing_fonts = crate::fonts::missing_fonts(&p); let mut st = DocState::new(p, path, title); st.missing_fonts = missing_fonts; s.add_document(st) } +/// A presentation read from disk, not yet added to a session: [`read_presentation_file`] needs no +/// session, so a host can run it on a background thread, hand the result to +/// [`Session::stage_loaded`] and add it with `file.adopt`. +#[derive(Debug)] +pub struct LoadedFile { + pub path: String, + pub doc: Presentation, + /// A native or PPTX file (saving goes back to it); an outline import has no file to save to. + pub native: bool, + /// Pictures and media a `.deckcraft` file had lost or damaged. + pub damaged: Vec, +} + +/// Read and parse the presentation at `path`. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_presentation_file(path: &str) -> Result { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + let (doc, damaged) = open_checked(path, &bytes)?; + let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); + Ok(LoadedFile { path: path.to_string(), doc, native, damaged }) +} + +/// Add a loaded presentation as the active document: the shared end of `file.open` and +/// `file.adopt`. A damaged file opens as an untitled repaired copy, so Save can't overwrite the +/// original with the empty media. +fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result { + let LoadedFile { path, doc, native, damaged } = f; + let keep_path = native && damaged.is_empty(); + let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc); + report_damage(s, &damaged)?; + let st = s.doc()?; + Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged})) +} + #[cfg(not(target_arch = "wasm32"))] fn open(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let doc = open_presentation(path, &bytes)?; - let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); - let i = add_opened(s, path, native.then(|| path.to_string()), doc); - let st = s.doc()?; - Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts})) + adopt_loaded(s, read_presentation_file(path)?) +} + +/// `file.adopt {path, token?}`: add the presentation a host read in the background and staged +/// with [`Session::stage_loaded`]. Without that staged file (a replayed journal, a stale token) +/// it reads `path` like `file.open`, so the journal entry stays replayable. +fn adopt(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.adopt", "missing `path`"))?; + let staged = p.get("token").and_then(Value::as_u64).and_then(|t| s.take_loaded(t)).filter(|f| f.path == path); + match staged { + Some(f) => adopt_loaded(s, f), + #[cfg(not(target_arch = "wasm32"))] + None => adopt_loaded(s, read_presentation_file(path)?), + #[cfg(target_arch = "wasm32")] + None => Err(bad("file.adopt", format!("{path} was not loaded"))), + } } #[cfg(target_arch = "wasm32")] fn open(_s: &mut Session, _p: &Value) -> Result { @@ -112,23 +233,46 @@ fn open(_s: &mut Session, _p: &Value) -> Result { fn open_bytes(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Presentation"); let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?; - let doc = open_presentation(name, &data)?; + let (doc, damaged) = open_checked(name, &data)?; let i = add_opened(s, name, None, doc); - Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts})) + report_damage(s, &damaged)?; + Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged})) } -/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`). +/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired. +fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> { + if damaged.is_empty() { + return Ok(()); + } + let st = s.doc_mut()?; + st.title = format!("{} (Repaired)", st.title); + let n = damaged.len(); + let text = format!( + "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.", + if n == 1 { "" } else { "s" }, + if n == 1 { "" } else { "s" }, + if n == 1 { "is" } else { "are" }, + ); + s.ui_requests.push(crate::UiRequest::Message { text }); + Ok(()) +} + +/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`). pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { match format { - "pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())), + f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())), "outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), + "gif" => gif_bytes(doc, &Value::Null), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), } } +/// Paper sizes for notes and handouts, for the UI's Print page. +pub use deckcraft_pdf::{PAPERS, paper_size}; + /// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides, -/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of +/// includeHidden, textLayer, frame, paper: letter|a4|legal|a3, orientation: portrait|landscape}` for a presentation of `count` slides. `slides` is a list of /// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based). pub fn pdf_options(p: &Value, count: usize) -> Result { let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() }; @@ -146,6 +290,16 @@ pub fn pdf_options(p: &Value, count: usize) -> Result o.include_hidden = bool_or(p, "includeHidden", false); o.text_layer = bool_or(p, "textLayer", true); o.frame_slides = bool_or(p, "frame", true); + let landscape = match str_param(p, "orientation").unwrap_or("portrait") { + "portrait" => false, + "landscape" => true, + other => return Err(bad("file.export", format!("orientation “{other}” isn't portrait or landscape"))), + }; + let paper = str_param(p, "paper").unwrap_or("letter"); + o.paper = deckcraft_pdf::paper_size(paper, landscape).ok_or_else(|| { + let ids: Vec<&str> = deckcraft_pdf::PAPERS.iter().map(|p| p.0).collect(); + bad("file.export", format!("paper “{paper}” isn't one of {}", ids.join(", "))) + })?; Ok(o) } @@ -182,16 +336,38 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string())) } +/// The output extensions [`format_for_path`] maps to a writer of that format. +pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"]; + +/// The format a file named `path` is written in, or `None` when its extension names no format +/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save +/// those as native `.deckcraft` bytes under the wrong name. +pub fn writable_format_for_path(path: &str) -> Option<&'static str> { + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path)) +} + +/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]). +pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result> { + Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes) +} + pub fn format_for_path(path: &str) -> &'static str { let l = path.to_ascii_lowercase(); - if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") { + if l.ends_with(".pptx") { "pptx" + } else if l.ends_with(".potx") { + "potx" + } else if l.ends_with(".ppsx") { + "ppsx" } else if l.ends_with(".png") { "png" } else if l.ends_with(".jpg") || l.ends_with(".jpeg") { "jpeg" } else if l.ends_with(".pdf") { "pdf" + } else if l.ends_with(".gif") { + "gif" } else if l.ends_with(".txt") || l.ends_with(".md") { "outline" } else { @@ -213,16 +389,8 @@ fn mark_saved(s: &mut Session, path: Option) -> Result<()> { Ok(()) } -#[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - // Write to a temporary sibling then rename, so a failed save never truncates the old file. - let tmp = format!("{path}.saving"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} -#[cfg(target_arch = "wasm32")] -fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other("saving to a path is not available in the browser".into())) + crate::io::write_file(path, bytes).map_err(EngineError::Other) } fn save(s: &mut Session, p: &Value) -> Result { @@ -252,11 +420,29 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result { Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()})) } +/// `scale` for rendering `doc`'s slides to an image: within 0.01–16, and small enough that the +/// slide fits the renderer's largest picture ([`deckcraft_render::MAX_SIDE`]). Past that the +/// renderer clips, so a 4× export of a 4032 pt slide came out cropped to its first 16,000 pixels. +pub fn image_scale(doc: &Presentation, scale: f64) -> f64 { + let scale = if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 }; + let longest = doc.slide_size.width.max(doc.slide_size.height).max(1.0); + scale.min(f64::from(deckcraft_render::MAX_SIDE) / longest) +} + /// Render slide `index` (current when absent) to PNG bytes. pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { - let img = - deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() }); - (img.to_png(), img.width, img.height) + let scale = image_scale(doc, scale); + let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() }); + (img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height) +} + +fn finite_scale(scale: f64) -> f64 { + if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 } +} + +fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec { + let dpi = deckcraft_render::dpi_for_scale(scale); + if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) } } fn render(s: &mut Session, p: &Value) -> Result { @@ -269,11 +455,34 @@ fn render(s: &mut Session, p: &Value) -> Result { Ok(json!({"png": base64_encode(&png), "width": w, "height": h})) } +/// The colour the slide is drawn with at one point: what the Eyedropper takes. +fn color_at(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + if i >= st.doc.slides.len() { + return Err(bad("slide.colorAt", format!("no slide {i}"))); + } + let coord = |key: &str| { + p.get(key) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + .ok_or_else(|| bad("slide.colorAt", format!("`{key}` (a number, in slide units) is required"))) + }; + let (x, y) = (coord("x")?, coord("y")?); + let scale = f64_or(p, "scale", 2.0).clamp(0.01, 16.0); + let img = deckcraft_render::render_slide(&st.doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let (px, py) = (x * scale, y * scale); + let inside = px >= 0.0 && py >= 0.0 && px < f64::from(img.width) && py < f64::from(img.height); + let [r, g, b, a] = + img.straight_at(px as u32, py as u32).filter(|_| inside).ok_or_else(|| bad("slide.colorAt", "the point is outside the slide"))?; + Ok(json!({"color": format!("{r:02X}{g:02X}{b:02X}"), "alpha": a, "x": x, "y": y})) +} + fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string(); let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string(); let st = s.doc()?; - let scale = f64_or(p, "scale", 2.0); + let scale = finite_scale(f64_or(p, "scale", 2.0)); match format.as_str() { "png" | "jpeg" | "jpg" => { let slides: Vec = match usize_param(p, "slide") { @@ -281,17 +490,15 @@ fn export(s: &mut Session, p: &Value) -> Result { None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(), None => vec![st.selection.slide], }; + // Every slide checked before any file is written, so a bad index leaves nothing behind. + if let Some(i) = slides.iter().find(|i| **i >= st.doc.slides.len()) { + return Err(bad("file.export", format!("no slide {i}"))); + } + let scale = image_scale(&st.doc, scale); let mut written = vec![]; for (k, i) in slides.iter().enumerate() { - if *i >= st.doc.slides.len() { - return Err(bad("file.export", format!("no slide {i}"))); - } - let img = deckcraft_render::render_slide( - &st.doc, - *i, - &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() }, - ); - let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) }; + let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let bytes = picture_bytes(&img, &format, scale); let out = if slides.len() == 1 { path.clone() } else { @@ -300,7 +507,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone()); pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string() }; - write_file(&out, &bytes)?; + // Say which slides made it when one can't be written (disk full part-way). + write_file(&out, &bytes) + .map_err(|e| if written.is_empty() { e } else { EngineError::Other(format!("{e} (slides 1–{k} were written)")) })?; written.push(out); } Ok(json!({"files": written})) @@ -310,11 +519,29 @@ fn export(s: &mut Session, p: &Value) -> Result { write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } - other => { - let bytes = save_bytes(&st.doc, other)?; + "gif" => { + // `slides` may be a range, as for PDF ("1-3,5"). + let mut gp = p.clone(); + if let Some(text) = p.get("slides").and_then(Value::as_str) { + gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?); + } + let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?; + write_file(&path, &out.bytes)?; + Ok(json!({ + "path": path, + "bytes": out.bytes.len(), + "frames": out.frames, + "width": out.width, + "height": out.height, + "secondsPerSlide": f64::from(out.delay_cs) / 100.0, + })) + } + "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => { + let bytes = save_bytes(&st.doc, &format)?; write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } + other => Err(bad("file.export", format!("unknown format `{other}`"))), } } @@ -323,7 +550,7 @@ fn info(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let doc = &st.doc; #[cfg(not(target_arch = "wasm32"))] - let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len()); + let bytes = st.path.as_deref().and_then(crate::io::file_len); #[cfg(target_arch = "wasm32")] let bytes: Option = None; Ok(json!({ @@ -430,3 +657,47 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result { } ok() } + +#[cfg(test)] +#[path = "color_at_tests.rs"] +mod color_at_tests; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::write_file; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_file_does_not_touch_a_predictable_saving_sibling() { + let dir = scratch("sibling"); + let dest = dir.join("deck.deckcraft"); + let dest_str = dest.to_str().unwrap(); + std::fs::write(&dest, b"old").unwrap(); + std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap(); + write_file(dest_str, b"new").unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"new"); + assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious"); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn write_file_failed_rename_keeps_destination_and_cleans_up() { + let dir = scratch("failed"); + let dest = dir.join("target"); + std::fs::create_dir(&dest).unwrap(); + assert!(write_file(dest.to_str().unwrap(), b"new").is_err()); + assert!(dest.is_dir()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..fd7a5232 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -3,8 +3,8 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; -use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; -use deckcraft_model::{Effects, Fill, Line, Shape}; +use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; +use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; use serde_json::{Value, json}; use super::text::{format_body, format_para, format_run}; @@ -13,6 +13,8 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add), + cmd!(noundo "font.rescan", "Rescan Installed Fonts", ["Home", "Font"], None, "{} → {faces, families} (fonts installed since start-up)", always, font_rescan), cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold), cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic), cmd!( @@ -39,14 +41,24 @@ pub fn specs() -> Vec { cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size), cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow), cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink), + cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color), cmd!( - "format.color", - "Font Color", - ["Home", "Font"], + "format.textTransparency", + "Text Transparency", + ["Shape Format", "WordArt Styles"], None, - "{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}", + "{value: 0..1} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)", has_text_or_shapes, - color + text_transparency + ), + cmd!( + "format.language", + "Language", + ["Review", "Language"], + None, + "{lang?, noProof?: bool} (proofing language of the selected text, and Do Not Check Spelling)", + has_text_or_shapes, + language ), cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), cmd!( @@ -69,6 +81,15 @@ pub fn specs() -> Vec { spacing ), cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!( + "format.fontDialog", + "Font…", + ["Home", "Font"], + Some("Cmd+T"), + "{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog", + has_text_or_shapes, + font_dialog + ), cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), cmd!( "format.textOutline", @@ -79,6 +100,15 @@ pub fn specs() -> Vec { has_text_or_shapes, text_outline ), + cmd!( + "format.textWarp", + "Text Transform", + ["Shape Format", "WordArt Styles"], + None, + "{preset: a prstTxWarp name or none, adj?: [finite raw guide values, at most 8]} — none clears the transform; omitted adj uses the preset's defaults", + has_text_or_shapes, + text_warp + ), cmd!( "format.align", "Align", @@ -115,7 +145,7 @@ pub fn specs() -> Vec { "Numbering", ["Home", "Paragraph"], None, - "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n}", + "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}", has_text_or_shapes, numbering ), @@ -139,6 +169,25 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler), + cmd!( + "format.paragraphDialog", + "Paragraph…", + ["Home", "Paragraph"], + None, + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + has_text_or_shapes, + paragraph_dialog + ), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs; with none of them, opens the Tabs dialog (pos in pt from the text area's left)", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -157,7 +206,7 @@ pub fn specs() -> Vec { "Text Box Margins", ["Format Shape", "Text Box"], None, - "{left?, top?, right?, bottom?: pt}", + "{left?: pt, top?: pt, right?: pt, bottom?: pt}", has_text_or_shapes, margins ), @@ -293,6 +342,59 @@ fn color(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; 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. +fn text_transparency(s: &mut Session, p: &Value) -> Result { + 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); + }) +} +fn language(s: &mut Session, p: &Value) -> Result { + let lang = str_param(p, "lang").map(str::to_string); + let no_proof = bool_param(p, "noProof"); + if lang.is_none() && no_proof.is_none() { + return Err(bad("format.language", "give `lang` or `noProof`")); + } + if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { + return Err(bad("format.language", "`lang` is a language tag such as en-US")); + } + format_run(s, &move |r| { + if let Some(l) = &lang { + r.lang = Some(l.clone()); + } + if let Some(n) = no_proof { + r.no_proof = n.then_some(true); + } + }) +} fn highlight(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color"); format_run(s, &move |r| r.highlight = c.clone()) @@ -309,6 +411,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result { 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, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +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> { + 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> { + 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 { + 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| super::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 +} + +fn font_dialog(s: &mut Session, p: &Value) -> Result { + 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)) +} fn clear(s: &mut Session, _p: &Value) -> Result { format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) } @@ -335,6 +644,36 @@ fn text_outline(s: &mut Session, p: &Value) -> Result { format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w))) } +fn text_warp(s: &mut Session, p: &Value) -> Result { + 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); + }) +} + fn case(s: &mut Session, p: &Value) -> Result { let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); let st = s.doc()?; @@ -442,7 +781,7 @@ fn bullets(s: &mut Session, p: &Value) -> Result { 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); + 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 { @@ -471,9 +810,17 @@ fn numbering(s: &mut Session, p: &Value) -> Result { 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 { @@ -542,6 +889,179 @@ fn paragraph(s: &mut Session, p: &Value) -> Result { }) } +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()) +} + +fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + 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)) + } + } +} + +fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + 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) -> (&'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 = 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)) } +} + +fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + 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)); + } + }) +} + +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, + }) +} + +fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = 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)}) +} + fn anchor(s: &mut Session, p: &Value) -> Result { let a = match str_param(p, "anchor").unwrap_or("top") { "middle" | "center" | "ctr" => Anchor::Middle, @@ -678,7 +1198,387 @@ fn state(s: &mut Session, _p: &Value) -> Result { "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). +fn font_add(_s: &mut Session, p: &Value) -> Result { + 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. +fn font_rescan(_s: &mut Session, _p: &Value) -> Result { + 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()})) +} + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +fn ruler(s: &mut Session, _p: &Value) -> Result { + 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 = super::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 = 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; + +fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let fin = |v: Option| 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 = 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); + } + }) +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike}; + use deckcraft_model::{ColorBase, Fill, SchemeSlot}; + use serde_json::json; + + use super::*; + use crate::{Session, UiRequest}; + + fn shape() -> Session { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap(); + s + } + + fn props(s: &Session) -> RunProps { + s.doc() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .unwrap() + .props + .clone() + } + + #[test] + fn font_dialog_applies_character_formatting_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.fontDialog", + &json!({ + "family": "Liberation Serif", + "size": 28, + "bold": true, + "italic": false, + "underline": "wavy", + "color": "#112233", + "strike": "dbl", + "baseline": "super", + "caps": "small", + "spacing": 3 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…"); + let r = props(&s); + assert_eq!(r.font.as_deref(), Some("Liberation Serif")); + assert_eq!(r.size, Some(28.0)); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.italic, Some(false)); + assert_eq!(r.underline.as_deref(), Some("wavy")); + assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. }))); + assert_eq!(r.strike, Some(Strike::Double)); + assert_eq!(r.baseline, Some(0.3)); + assert_eq!(r.caps, Some(Caps::Small)); + assert_eq!(r.spacing, Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none()); + assert_eq!(r.strike, None); + assert_eq!(r.caps, None); + } + + #[test] + fn font_dialog_changes_only_the_fields_it_is_given() { + let mut s = shape(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(22.0)); + assert!(r.font.is_none() && r.italic.is_none()); + let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") }; + assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 })); + assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod")); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert!(r.size.is_none() && r.fill.is_none()); + } + + #[test] + fn font_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + s.execute("format.italic", &json!({"on": true})).unwrap(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.fontDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "font"); + assert_eq!(params["italic"], true); + assert_eq!(params["bold"], false); + assert!(params["family"].as_str().is_some_and(|f| !f.is_empty())); + assert!(params["size"].as_f64().is_some_and(|n| n > 0.0)); + } + + #[test] + fn font_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err(); + assert!(err.to_string().contains("underline"), "{err}"); + assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert!(props(&s).underline.is_none()); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); + } + + fn para(s: &Session) -> ParaProps { + s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone() + } + + #[test] + fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.paragraphDialog", + &json!({ + "align": "justify", + "indentLeft": 36.0, + "indentRight": 18.0, + "special": "hanging", + "specialBy": 18.0, + "spaceBefore": 6.0, + "spaceAfter": 8.0, + "lineSpacing": 1.5 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.margin_left, Some(36.0)); + assert_eq!(p.margin_right, Some(18.0)); + assert_eq!(p.indent, Some(-18.0)); + assert_eq!(p.space_before, Some(Spacing::Pts(6.0))); + assert_eq!(p.space_after, Some(Spacing::Pts(8.0))); + assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5))); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none()); + } + + #[test] + fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() { + let mut s = shape(); + s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap(); + s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Right)); + assert_eq!(p.indent, Some(27.0)); + assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0))); + assert!(p.margin_left.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).align, Some(Align::Right)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.paragraphDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "paragraph"); + assert_eq!(params["align"], "center", "a new shape's text is centred"); + assert_eq!(params["special"], "none"); + assert_eq!(params["lineKind"], "multiple"); + assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0)); + } + + #[test] + fn paragraph_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align")); + assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(para(&s).align, Some(Align::Center)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.tabs", + &json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…"); + let p = para(&s); + let tabs = p.tabs.unwrap(); + assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse"); + assert_eq!(tabs[0].pos, 72.02); + assert_eq!(tabs[0].align, "r"); + assert_eq!(tabs[1].align, "ctr"); + assert_eq!(p.default_tab, Some(36.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none()); + + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "tabs"); + assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty())); + assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err()); + assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + } +} diff --git a/crates/engine/src/cmd/guides_tests.rs b/crates/engine/src/cmd/guides_tests.rs new file mode 100644 index 00000000..80bf3e68 --- /dev/null +++ b/crates/engine/src/cmd/guides_tests.rs @@ -0,0 +1,76 @@ +//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and +//! the PPTX round trip. + +use serde_json::json; + +use crate::Session; +use crate::tools::{Mods, PointerEvent, PointerKind}; + +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); +} + +#[test] +fn add_move_delete_and_undo() { + let mut s = Session::with_new(); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none"); + let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(i, 2); + s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide"); + assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err()); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err()); + s.execute("guide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 3); +} + +#[test] +fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + // Hidden guides can't be grabbed: the press starts a marquee instead. + ev(&mut s, PointerKind::Down, 300.0, 50.0); + ev(&mut s, PointerKind::Up, 300.0, 50.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0); + s.tool.guides_visible = true; + ev(&mut s, PointerKind::Down, 301.0, 50.0); + ev(&mut s, PointerKind::Drag, 350.4, 60.0); + assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging"); + ev(&mut s, PointerKind::Up, 350.4, 60.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0); + assert_eq!(s.tool.guide_drag, None); + + // A shape dragged near the guide snaps its left edge onto it. + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap(); + s.prefs.snap_to_grid = false; + ev(&mut s, PointerKind::Down, 140.0, 230.0); + ev(&mut s, PointerKind::Drag, 200.0, 230.0); + ev(&mut s, PointerKind::Drag, 388.0, 231.0); + ev(&mut s, PointerKind::Up, 388.0, 231.0); + let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap(); + assert!((x.x - 350.0).abs() < 0.01, "{x:?}"); + + // Dropped off the slide: gone, and undo brings it back. + ev(&mut s, PointerKind::Down, 350.0, 10.0); + ev(&mut s, PointerKind::Drag, -40.0, 10.0); + ev(&mut s, PointerKind::Up, -40.0, 10.0); + assert!(s.doc().unwrap().doc.guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 1); +} + +#[test] +fn guides_round_trip_through_pptx() { + let mut s = Session::with_new(); + s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.guides, s.doc().unwrap().doc.guides); + let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap(); + assert!(none.guides.is_empty()); +} diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs new file mode 100644 index 00000000..45676754 --- /dev/null +++ b/crates/engine/src/cmd/ideas.rs @@ -0,0 +1,325 @@ +//! Design Ideas: arrangements of what is on the current slide (picture full-bleed with the title +//! on a band, picture on one half, a framed picture, text-only layouts). An idea moves and +//! resizes the slide's own shapes and crops pictures to their new box; it changes only that +//! slide. Everything is computed here, offline. + +use deckcraft_geom::Xfrm; +use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Selection, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(query "design.ideas", "Design Ideas", [], None, "{slide?} → [{id, label}] ideas for the slide", has_slide, ideas_cmd), + cmd!("design.applyIdea", "Apply Design Idea", ["Home", "Designer"], None, "{id, slide?}", has_slide, apply_cmd), + ] +} + +/// An idea: id and label. +pub type Idea = (&'static str, &'static str); + +const PICTURE_IDEAS: [Idea; 4] = [ + ("fullBleed", "Full-bleed picture, title on a band"), + ("pictureLeft", "Picture left, text right"), + ("pictureRight", "Picture right, text left"), + ("framed", "Framed picture beside the title"), +]; +const TEXT_IDEAS: [Idea; 3] = + [("titleColumn", "Title column beside the text"), ("centred", "Centred title over the text"), ("titleBottom", "Text above, title at the foot")]; + +/// The roles of a slide's top-level shapes, by index. +struct Roles { + title: Option, + picture: Option, + text: Vec, +} + +fn roles(shapes: &[Shape]) -> Roles { + let mut r = Roles { title: None, picture: None, text: vec![] }; + for (i, s) in shapes.iter().enumerate() { + if s.hidden { + continue; + } + if s.ph.as_ref().is_some_and(|p| p.kind.is_title()) && r.title.is_none() { + r.title = Some(i); + } else if matches!(s.kind, ShapeKind::Picture { .. }) { + // The largest picture leads. + let area = |j: usize| shapes.get(j).and_then(|s| s.xfrm).map(|x| x.w * x.h).unwrap_or(0.0); + if r.picture.is_none_or(|p| area(i) > area(p)) { + r.picture = Some(i); + } + } else if s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()) { + r.text.push(i); + } + } + r +} + +/// The ideas for slide `index` (none when it has nothing to arrange). +pub fn ideas_for(doc: &Presentation, index: usize) -> Vec { + let Some(slide) = doc.slides.get(index) else { return vec![] }; + let r = roles(&slide.shapes); + if r.picture.is_some() { + PICTURE_IDEAS.to_vec() + } else if r.title.is_some() && !r.text.is_empty() { + TEXT_IDEAS.to_vec() + } else { + vec![] + } +} + +/// Crop a picture whose source has aspect `src` (w/h) to fill a box of aspect `dst`, centred. +fn cover_crop(src: f64, dst: f64) -> [f64; 4] { + if !(src.is_finite() && dst.is_finite() && src > 0.0 && dst > 0.0) { + return [0.0; 4]; + } + if src > dst { + let cut = (1.0 - dst / src) / 2.0; + [cut, 0.0, cut, 0.0] + } else { + let cut = (1.0 - src / dst) / 2.0; + [0.0, cut, 0.0, cut] + } +} + +/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). +pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> { + if !ideas_for(doc, index).iter().any(|i| i.0 == id) { + return Err(bad("design.applyIdea", format!("no idea `{id}` for this slide (see design.ideas)"))); + } + let (sw, sh) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0)); + let sel = Selection { slide: index, ..sel.clone() }; + let shapes = doc.slides.get(index).map(|s| s.shapes.clone()).ok_or_else(|| bad("design.applyIdea", "no slide"))?; + let r = roles(&shapes); + let boxes: Vec = shapes.iter().map(|s| xfrm_of(doc, &sel, s)).collect(); + let m = sw.min(sh) * 0.06; + let mut place: Vec<(usize, Xfrm)> = vec![]; + // Text shapes stacked in a box. + let stack = |b: Xfrm, out: &mut Vec<(usize, Xfrm)>| { + let n = r.text.len().max(1) as f64; + let gap = m * 0.4; + let h = ((b.h - gap * (n - 1.0)) / n).max(1.0); + for (k, i) in r.text.iter().enumerate() { + out.push((*i, Xfrm::new(b.x, b.y + k as f64 * (h + gap), b.w, h))); + } + }; + let mut band = false; + match id { + "fullBleed" => { + if let Some(p) = r.picture { + place.push((p, Xfrm::new(0.0, 0.0, sw, sh))); + } + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, sh * 0.68, sw - 2.0 * m, sh * 0.2))); + band = true; + } + stack(Xfrm::new(m, sh * 0.06, sw * 0.45, sh * 0.5), &mut place); + } + "pictureLeft" | "pictureRight" => { + let left = id == "pictureLeft"; + let half = sw * 0.5; + let (px, tx) = if left { (0.0, half + m) } else { (half, m) }; + if let Some(p) = r.picture { + place.push((p, Xfrm::new(px, 0.0, half, sh))); + } + let tw = half - 2.0 * m; + if let Some(t) = r.title { + place.push((t, Xfrm::new(tx, m, tw, sh * 0.24))); + } + stack(Xfrm::new(tx, m + sh * 0.28, tw, sh - 2.0 * m - sh * 0.28), &mut place); + } + "framed" => { + if let Some(p) = r.picture { + place.push((p, Xfrm::new(sw * 0.42, m * 1.5, sw * 0.58 - m * 1.5, sh - 3.0 * m))); + } + let tw = sw * 0.42 - 2.0 * m; + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, m * 1.5, tw, sh * 0.3))); + } + stack(Xfrm::new(m, m * 1.5 + sh * 0.34, tw, sh - 3.0 * m - sh * 0.34), &mut place); + } + "titleColumn" => { + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, m, sw * 0.32, sh - 2.0 * m))); + } + stack(Xfrm::new(sw * 0.36, m, sw * 0.64 - m, sh - 2.0 * m), &mut place); + } + "centred" => { + if let Some(t) = r.title { + place.push((t, Xfrm::new(sw * 0.1, m, sw * 0.8, sh * 0.26))); + } + stack(Xfrm::new(sw * 0.18, m + sh * 0.3, sw * 0.64, sh - 2.0 * m - sh * 0.3), &mut place); + } + "titleBottom" => { + stack(Xfrm::new(m, m, sw - 2.0 * m, sh * 0.62), &mut place); + if let Some(t) = r.title { + place.push((t, Xfrm::new(m, sh * 0.72, sw - 2.0 * m, sh * 0.2))); + } + } + _ => return Err(bad("design.applyIdea", format!("no idea `{id}`"))), + } + let slide = doc.slides.get_mut(index).ok_or_else(|| bad("design.applyIdea", "no slide"))?; + let slide = std::sync::Arc::make_mut(slide); + for (i, x) in place { + let (Some(s), Some(old)) = (slide.shapes.get_mut(i), boxes.get(i)) else { continue }; + if let ShapeKind::Picture { fill } = &mut s.kind { + // The source's aspect, from the current box and crop. + let c = fill.crop; + let (fw, fh) = ((1.0 - c[0] - c[2]).max(0.01), (1.0 - c[1] - c[3]).max(0.01)); + let src = (old.w / fw) / (old.h / fh).max(1e-6); + fill.crop = cover_crop(src, x.w / x.h.max(1e-6)); + } + s.xfrm = Some(Xfrm { rot: 0.0, ..x }); + } + // The picture goes to the back, so text over it stays readable. + if let Some(p) = r.picture + && p < slide.shapes.len() + { + let pic = slide.shapes.remove(p); + slide.shapes.insert(0, pic); + } + if 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)))); + } + Ok(()) +} + +fn ideas_cmd(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>())) +} + +fn apply_cmd(s: &mut Session, p: &Value) -> Result { + let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string(); + let slide = usize_param(p, "slide"); + s.edit(|doc, sel| { + let i = slide.unwrap_or(sel.slide); + apply_idea(doc, sel, i, &id)?; + Ok(json!({"slide": i, "id": id})) + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use std::sync::Arc; + + use deckcraft_model::style::PictureFill; + use deckcraft_model::{PhType, Placeholder, ShapeId}; + + use super::*; + + fn deck(picture: bool) -> Session { + let mut s = Session::with_new(); + let st = s.doc_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.clear(); + let title = Shape { + id: ShapeId(500), + ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }), + xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)), + text: Some(deckcraft_model::text::TextBody::from_text("Harbour at dawn")), + ..Default::default() + }; + let body = Shape { + id: ShapeId(501), + xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)), + text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")), + ..Default::default() + }; + slide.shapes.push(title); + slide.shapes.push(body); + if picture { + // A 2:1 picture covering most of the slide, over the title. + let pic = Shape { + id: ShapeId(502), + kind: ShapeKind::Picture { fill: PictureFill::default() }, + xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 450.0)), + ..Default::default() + }; + slide.shapes.push(pic); + } + s + } + + fn shape(s: &Session, id: u32) -> Shape { + s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.id == ShapeId(id)).cloned().unwrap() + } + + fn overlaps(a: Xfrm, b: Xfrm) -> bool { + a.x < b.x + b.w - 0.5 && b.x < a.x + a.w - 0.5 && a.y < b.y + b.h - 0.5 && b.y < a.y + a.h - 0.5 + } + + #[test] + fn picture_slides_get_layout_ideas_and_text_slides_get_text_ideas() { + let s = deck(true); + let ids: Vec<&str> = ideas_for(&s.doc().unwrap().doc, 0).iter().map(|i| i.0).collect(); + assert_eq!(ids, ["fullBleed", "pictureLeft", "pictureRight", "framed"]); + let s = deck(false); + assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3); + assert!(ideas_for(&s.doc().unwrap().doc, 9).is_empty()); + } + + #[test] + fn picture_left_puts_text_beside_the_picture_cropped_to_fill() { + let mut s = deck(true); + let theme_before = s.doc().unwrap().doc.masters[0].theme.name.clone(); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + let (pic, title, body) = (shape(&s, 502), shape(&s, 500), shape(&s, 501)); + let px = pic.xfrm.unwrap(); + assert_eq!((px.x, px.y, px.w, px.h), (0.0, 0.0, 480.0, 540.0)); + assert!(!overlaps(px, title.xfrm.unwrap()) && !overlaps(px, body.xfrm.unwrap())); + assert!(!overlaps(title.xfrm.unwrap(), body.xfrm.unwrap())); + // 2:1 source into an 8:9 box: cut equally from left and right. + let ShapeKind::Picture { fill } = &pic.kind else { panic!() }; + assert!(fill.crop[0] > 0.25 && (fill.crop[0] - fill.crop[2]).abs() < 1e-9 && fill.crop[1] == 0.0, "{:?}", fill.crop); + // Only this slide's arrangement changed, not the theme; undo restores it. + assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, theme_before); + assert_eq!(s.doc().unwrap().doc.slides[0].shapes[0].id, ShapeId(502), "picture at the back"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shape(&s, 502).xfrm.unwrap().w, 900.0); + } + + #[test] + fn full_bleed_covers_the_slide_and_puts_the_title_on_a_band() { + let mut s = deck(true); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + let px = shape(&s, 502).xfrm.unwrap(); + assert_eq!((px.w, px.h), (960.0, 540.0)); + let t = shape(&s, 500); + assert!(t.fill.is_some()); + assert!(t.xfrm.unwrap().y > 300.0); + } + + #[test] + fn every_idea_keeps_shapes_on_the_slide_and_unknown_ideas_are_refused() { + for pic in [true, false] { + let probe = deck(pic); + for (id, _) in ideas_for(&probe.doc().unwrap().doc, 0) { + let mut s = deck(pic); + s.execute("design.applyIdea", &json!({"id": id})).unwrap(); + for sh in &s.doc().unwrap().doc.slides[0].shapes { + let x = sh.xfrm.unwrap(); + assert!(x.x >= -0.01 && x.y >= -0.01 && x.x + x.w <= 960.01 && x.y + x.h <= 540.01 && x.w > 0.0 && x.h > 0.0, "{id}: {x:?}"); + } + } + } + let mut s = deck(false); + assert!(s.execute("design.applyIdea", &json!({"id": "fullBleed"})).is_err()); + assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err()); + } + + #[test] + fn cover_crop_matches_the_box_aspect() { + let c = cover_crop(2.0, 1.0); + assert!((c[0] - 0.25).abs() < 1e-12 && c[1] == 0.0); + let c = cover_crop(1.0, 2.0); + assert!((c[1] - 0.25).abs() < 1e-12 && c[0] == 0.0); + assert_eq!(cover_crop(f64::NAN, 1.0), [0.0; 4]); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..149957df 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -5,7 +5,7 @@ use deckcraft_geom::Xfrm; use deckcraft_model::chart::ChartType; use deckcraft_model::style::PictureFill; use deckcraft_model::text::{AutoFit, RunKind, TextBody}; -use deckcraft_model::{Chart, ColorRef, Fill, Geom, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; +use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; use serde_json::{Value, json}; use super::*; @@ -22,6 +22,16 @@ pub fn specs() -> Vec { has_slide, insert_shape ), + cmd!( + "insert.icon", + "Icons", + ["Insert", "Illustrations"], + None, + "{name: star|arrow-right|… (see insert.icons), rect?: [x,y,w,h]}", + has_slide, + insert_icon + ), + cmd!(query "insert.icons", "Icon Gallery", [], None, "{} → [{name, label, category}]", always, list_icons), cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets), cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box), cmd!( @@ -33,6 +43,24 @@ pub fn specs() -> Vec { has_slide, picture ), + cmd!( + "insert.photoAlbum", + "Photo Album…", + ["Insert", "Images"], + None, + "{pictures?: [{path?|data: base64, name?, rot?: deg, brightness?: -1..1, contrast?: -1..1} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool, grayscale?: bool, theme?: name} — omit pictures to open the dialog", + has_doc, + photo_album + ), + cmd!( + "insert.photoAlbumEdit", + "Edit Photo Album…", + ["Insert", "Images"], + None, + "{album?: id, pictures?: same as insert.photoAlbum, layout?, frame?, captions?, grayscale?, theme?} — omit pictures to reopen the dialog on the current album", + has_doc, + photo_album_edit + ), cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), cmd!( "insert.chart", @@ -47,14 +75,31 @@ pub fn specs() -> Vec { cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), - cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), + cmd!( + "insert.dateTime", + "Date & Time", + ["Insert", "Text"], + None, + "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog", + has_slide, + date_time + ), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), + cmd!( + "shape.action", + "Action Settings", + ["Insert", "Links"], + None, + "{on?: click|hover, action: none|next|previous|first|last|lastViewed|endShow|playMedia|slide|url|customShow|program, slide?: index, url?, show?: custom show name, path?, highlight?: bool, tooltip?, ids?}", + has_selection, + shape_action + ), cmd!( "insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), - "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}", + "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, highlightClick?, ids?}", has_text_or_shapes, hyperlink ), @@ -64,7 +109,7 @@ pub fn specs() -> Vec { "Edit Data", ["Chart Design", "Data"], None, - "{categories?: [..], series?: [{name, values}], id?}", + "{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}", has_selection, chart_data ), @@ -73,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", has_selection, chart_options ), @@ -82,7 +127,7 @@ pub fn specs() -> Vec { "SmartArt", ["Insert", "Illustrations"], None, - "{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}", + "{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}", has_slide, smart_art ), @@ -104,9 +149,9 @@ fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm { Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } -pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Result { +pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result { s.edit(|doc, sel| { - shape.id = ShapeId(doc.alloc_id()); + shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); if shape.name.is_empty() { let base = match &shape.kind { ShapeKind::Picture { .. } => "Picture".to_string(), @@ -114,6 +159,7 @@ pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Resu ShapeKind::Chart(_) => "Chart".to_string(), ShapeKind::Media(m) if m.video => "Video".to_string(), ShapeKind::Media(_) => "Audio".to_string(), + ShapeKind::Model3d { .. } => "3D Model".to_string(), _ if shape.text_box => "TextBox".to_string(), _ => shape .geom @@ -176,7 +222,7 @@ fn insert_shape(s: &mut Session, p: &Value) -> Result { if let (Some(look), false) = (&s.default_look, line_like) { super::format::apply_painted(&mut shape, look); } - let id = add_shape(s, shape, true)?; + let id = add_shape(s, "shape.insert", shape, true)?; Ok(json!({"id": id})) } @@ -186,6 +232,46 @@ fn presets(_s: &mut Session, _p: &Value) -> Result { )) } +fn list_icons(_s: &mut Session, _p: &Value) -> Result { + Ok(Value::Array(crate::icons::icons().iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category})).collect())) +} + +/// A Lucide stroke icon: custom geometry, no fill, round line scaled from the 24-unit design. +fn insert_icon(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("insert.icon", "missing `name`"))?; + let icon = crate::icons::find(name).ok_or_else(|| bad("insert.icon", format!("unknown icon `{name}`")))?; + let d = crate::icons::path_data(icon.name).ok_or_else(|| bad("insert.icon", format!("could not build icon `{}`", icon.name)))?; + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, 72.0)); + if rect.w < 1.0 || rect.h < 1.0 { + return Err(bad("insert.icon", "rect needs a positive width and height")); + } + let shape = Shape { + name: icon.label.to_string(), + xfrm: Some(rect), + geom: Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: crate::icons::VIEW, + h: crate::icons::VIEW, + d, + fill: deckcraft_geom::preset::FillMode::None, + stroke: true, + }], + }, + fill: Some(Fill::None), + line: Some(Line { + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), + width: Some(crate::icons::stroke_width(icon, rect.w, rect.h)), + cap: Some(deckcraft_model::style::LineCap::Round), + join: Some(deckcraft_model::style::LineJoin::Round), + ..Default::default() + }), + style: Some(ShapeStyle::line(SchemeSlot::Tx1)), + ..Default::default() + }; + let id = add_shape(s, "insert.icon", shape, true)?; + Ok(json!({"id": id})) +} + pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape { let mut body = TextBody::from_text(text); body.body.wrap = Some(true); @@ -204,7 +290,7 @@ fn text_box(s: &mut Session, p: &Value) -> Result { t.body.wrap = Some(false); } } - let id = add_shape(s, shape, true)?; + let id = add_shape(s, "insert.textBox", shape, true)?; super::text::fit_text_box(s, id)?; if text.is_empty() { // Start typing immediately. @@ -222,9 +308,8 @@ pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { 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)); } - #[cfg(not(target_arch = "wasm32"))] if let Some(path) = str_param(p, "path") { - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + 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)); } @@ -341,7 +426,7 @@ fn picture(s: &mut Session, p: &Value) -> Result { } _ => [0.0; 4], }; - let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + 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() } }, @@ -368,10 +453,729 @@ fn picture(s: &mut Session, p: &Value) -> Result { })?; return Ok(json!({"id": ph_id})); } - let id = add_shape(s, shape, true)?; + let id = add_shape(s, "insert.picture", shape, true)?; Ok(json!({"id": id})) } +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, + 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, +} + +enum AlbumItem { + Picture(AlbumPic), + Text(String), +} + +enum AlbumSlide { + Pictures(Vec), + 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. +fn photo_album(s: &mut Session, p: &Value) -> Result { + 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) +} + +fn photo_album_edit(s: &mut Session, p: &Value) -> Result { + 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, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { + 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 = 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(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() +} + +fn album_layout(p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let n = album_finite(item, "rot")?; + Ok(((n % 360.0) + 360.0) % 360.0) +} + +fn album_unit(item: &Value, key: &str) -> Result { + 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> { + 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(|n| n.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, per: usize) -> Vec { + 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) { + 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) { + 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", ¶ms)?; + Ok(()) +} + +struct AlbumMark { + id: u32, + layout: String, + frame: String, + captions: bool, + grayscale: bool, + theme: String, +} + +fn parse_album_mark(raw: &str) -> Option { + let rest = raw.strip_prefix("deckcraft-album:")?; + let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); + let id = id_str.parse::().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 { + 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::().ok().filter(|v| *v != 0), + _ => None, + } +} + +fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { + 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 { + 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)> { + 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 = + 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 { + 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 { + 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, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { + 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})) +} + fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); @@ -389,7 +1193,7 @@ fn table(s: &mut Session, p: &Value) -> Result { } } } - let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; + let id = add_shape(s, "insert.table", Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; Ok(json!({"id": id})) } @@ -446,7 +1250,7 @@ fn chart(s: &mut Session, p: &Value) -> Result { } 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, Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; + let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; Ok(json!({"id": id})) } @@ -498,12 +1302,15 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result { }); 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); - let poster = poster_png.map(|png| doc.add_media(&format!("{name}-poster.png"), "image/png", png)); + 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, Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; + 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() }); } @@ -545,7 +1352,7 @@ fn word_art(s: &mut Session, p: &Value) -> Result { } } } - let id = add_shape(s, shape, true)?; + let id = add_shape(s, "insert.wordArt", shape, true)?; super::text::fit_text_box(s, id)?; Ok(json!({"id": id})) } @@ -562,10 +1369,198 @@ fn field_run(s: &mut Session, field: &str, text: &str) -> Result { fn slide_number(s: &mut Session, _p: &Value) -> Result { 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, + 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 { + 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 { + 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()) +} + fn date_time(s: &mut Session, p: &Value) -> Result { - let f = str_param(p, "format").unwrap_or("datetime1").to_string(); - // Saved with the date it shows now (the text kept where the date can't be computed). - field_run(s, &f, &super::design::date_field_text(&f)) + 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 { + if s.doc()?.selection.text.is_some() { + if o.update { + super::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 { + 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 { + 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 { + super::text::insert_field(s, &o.format, &o.sample)?; + } else { + super::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); + super::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(); + super::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(()) } fn symbol(s: &mut Session, p: &Value) -> Result { @@ -589,7 +1584,14 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result { 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: false }; + 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 super::text::link_text(s, link, &display); } @@ -599,6 +1601,55 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result { }) } +/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does +/// in a show. +fn shape_action(s: &mut Session, p: &Value) -> Result { + 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(()) + }) +} + fn action_button(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; let kind = str_param(p, "kind").unwrap_or("custom"); @@ -621,10 +1672,17 @@ fn action_button(s: &mut Session, p: &Value) -> Result { 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 }), + 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, shape, true)?; + let id = add_shape(s, "insert.actionButton", shape, true)?; Ok(json!({"id": id})) } @@ -644,7 +1702,12 @@ fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { 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) || c.series.len() == 1 && c.vary_colors; + 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(()) }) @@ -664,10 +1727,21 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { }) .collect() }); + let levels: Option>> = 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); @@ -688,7 +1762,46 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { } fn chart_options(s: &mut Session, p: &Value) -> Result { + // 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> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { + 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; + } + } + if let Some(t) = &subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = 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; + } + 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); } @@ -709,92 +1822,437 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { }) } -/// SmartArt-style graphics built from shapes (grouped), with our own layouts. +/// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with +/// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); - let items: Vec = - p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); - let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items }; - let n = items.len().clamp(1, 12); - let size = s.doc()?.doc.slide_size; - let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); - let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; - let mut shapes: Vec<(String, Xfrm, usize)> = vec![]; - match kind.as_str() { - "cycle" => { - let r = area.h.min(area.w) * 0.38; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let d = r * 0.62; - for i in 0..n { - let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i)); - } + let nodes = match p.get("nodes") { + Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?, + None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)), + }; + super::smartart::insert(s, &kind, nodes) +} + +#[cfg(test)] +mod tests { + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + Session::with_new() + } + + fn undo(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + fn date_run(s: &Session) -> Option { + s.doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() + } + + fn title_field(s: &Session) -> deckcraft_model::Run { + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + title.paragraphs[0].runs.iter().find(|r| r.text.contains(':') || matches!(r.kind, RunKind::Field { .. })).cloned().unwrap() + } + + fn edit_title(s: &mut Session) { + let id = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "On "})).unwrap(); + } + + #[test] + fn no_params_opens_the_dialog_and_edits_nothing() { + let mut s = session(); + let rev = s.doc().unwrap().revision; + for p in [json!({}), serde_json::Value::Null] { + s.ui_requests.clear(); + s.execute("insert.dateTime", &p).unwrap(); + assert!(matches!(&s.ui_requests[..], [crate::UiRequest::Dialog { id, .. }] if id == "dateTime")); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), 0); + assert!(date_run(&s).is_none()); } - "hierarchy" => { - let w = area.w / (n.max(2) - 1) as f64 * 0.8; - shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0)); - for i in 1..n { - let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64; - shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i)); - } + } + + #[test] + fn a_format_at_the_caret_is_one_updating_field() { + let mut s = session(); + edit_title(&mut s); + let before = undo(&s); + let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); + assert_eq!(v["where"], "caret"); + let run = title_field(&s); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); + assert!(run.text.contains(':'), "{run:?}"); + assert_eq!(undo(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Date & Time"); + s.execute("edit.undo", &json!({})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().text(); + assert_eq!(title, "On "); + } + + #[test] + fn update_false_inserts_plain_text_in_the_chosen_language() { + let mut s = session(); + edit_title(&mut s); + let before = undo(&s); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let expect = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now).unwrap(); + s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let run = title.paragraphs[0].runs.iter().find(|r| r.text == expect).cloned().unwrap(); + assert_eq!(run.kind, RunKind::Text); + assert_eq!(run.props.lang.as_deref(), Some("en-GB")); + assert_eq!(undo(&s), before + 1); + } + + #[test] + fn a_named_format_in_french_and_a_bad_value_change_nothing() { + let mut s = session(); + edit_title(&mut s); + let rev = s.doc().unwrap().revision; + let undos = undo(&s); + let err = s.execute("insert.dateTime", &json!({"format": "datetime2", "language": "fr-FR"})).unwrap_err(); + assert!(err.to_string().contains("isn't available"), "{err}"); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), undos); + for p in [ + json!({"format": "datetime14"}), + json!({"format": "nope"}), + json!({"update": "yes"}), + json!({"language": "de-DE"}), + json!({"language": ""}), + ] { + let err = s.execute("insert.dateTime", &p).unwrap_err(); + assert!(err.to_string().contains("insert.dateTime"), "{err}"); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(undo(&s), undos); } - "pyramid" => { - let h = area.h / n as f64; - for i in 0..n { - let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; - shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i)); - } + } + + #[test] + fn without_a_caret_the_date_lands_on_this_slide_only() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let clock = deckcraft_text::datetime::field_text("datetime10", Some("en-US"), now).unwrap(); + let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "language": "en_US"})).unwrap(); + assert_eq!(v["where"], "footer"); + let run = date_run(&s).unwrap(); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); + assert_eq!(run.text, clock); + assert_eq!(run.props.lang.as_deref(), Some("en-US")); + s.execute("slide.go", &json!({"index": 1})).unwrap(); + assert!(date_run(&s).is_none(), "the other slide keeps its own footer"); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let fixed = deckcraft_text::datetime::field_text("datetime12", None, now).unwrap(); + s.execute("insert.dateTime", &json!({"format": "datetime12", "update": false})).unwrap(); + let run = date_run(&s).unwrap(); + assert_eq!((run.kind, run.text), (RunKind::Text, fixed)); + s.execute("edit.undo", &json!({})).unwrap(); + let run = date_run(&s).unwrap(); + assert!(matches!(run.kind, RunKind::Field { .. }), "one undo restores the field"); + } +} + +#[cfg(test)] +mod icon_and_picture_tests { + use deckcraft_model::ShapeKind; + use deckcraft_model::style::LineJoin; + + use super::*; + + /// 8×8 red PNG. + fn png() -> Vec { + 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 { + 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 { + 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:?}"), } - "matrix" => { - let (w, h) = (area.w / 2.0, area.h / 2.0); - for i in 0..n.min(4) { - shapes.push(( - "roundRect".into(), - Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0), - i, - )); - } + } + + #[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}")})); } - "list" => { - let h = area.h / n as f64; - for i in 0..n { - shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), 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"), } - _ => { - let gap = 24.0; - let w = (area.w - gap * (n as f64 - 1.0)) / n as f64; - for i in 0..n { - shapes.push(( - if i + 1 < n { "homePlate".into() } else { "roundRect".into() }, - Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), - i, - )); + } + + 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()); } - let mut ids = vec![]; - for (preset, x, i) in shapes { - let mut sh = Shape { - xfrm: Some(x), - geom: Geom::preset(&preset), - style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))), - ..Default::default() - }; - let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or("")); - body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); - for para in &mut body.paragraphs { - para.props.align = Some(deckcraft_model::text::Align::Center); - for r in &mut para.runs { - r.props.size = Some(20.0); + + #[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; } } - sh.text = Some(body); - ids.push(add_shape(s, sh, false)?.0); - } - let g = s.execute("arrange.group", &json!({"ids": ids}))?; - if let Some(gid) = g.get("id").and_then(Value::as_u64) { - let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")})); + 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}"); } - Ok(g) } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index db162a38..9da535e0 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), - cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide), + cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide), cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), ] @@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result { "transition": serde_json::to_value(&sl.transition).unwrap_or_default(), "animations": serde_json::to_value(&sl.animations).unwrap_or_default(), "notes": sl.notes_text(), + "notesBody": sl.notes, "comments": sl.comments.len(), "hidden": sl.hidden, })) @@ -136,7 +137,8 @@ fn slide(s: &mut Session, p: &Value) -> Result { fn shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = id_param(p, "id").or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?; + let id = + id_param(p, "id", "shape.inspect")?.or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?; let sh = st.shape(id).ok_or_else(|| bad("shape.inspect", format!("no shape {id}")))?; let x = xfrm_of(&st.doc, &st.selection, sh); Ok(json!({"shape": serde_json::to_value(sh).unwrap_or_default(), "box": x})) diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 855370ad..d9a3147d 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -139,7 +139,7 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result { let data = s.doc()?.doc.media(m.media).map(|i| i.data.clone()).ok_or_else(|| EngineError::Other("the media data is missing".into()))?; let png = poster_png(&data, ms).ok_or_else(|| EngineError::Other("no frame could be decoded there".into()))?; let name = format!("poster{id}.png"); - let poster = s.edit(|doc, _| Ok(doc.add_media(&name, "image/png", png)))?; + let poster = s.edit(|doc, _| doc.add_media(&name, "image/png", png).ok_or_else(|| bad("media.posterFrame", NO_ID_SPACE)))?; edit_shapes(s, &json!({"id": id}), "media.posterFrame", |sh| { if let ShapeKind::Media(m) = &mut sh.kind { m.poster = Some(poster); diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index 4c9a6d28..45990321 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -30,39 +30,87 @@ pub fn specs() -> Vec { )] } -/// The filled outline of `s` in slide points (curves flattened). +/// The filled outline of `s` in slide points (curves flattened). A text box with no fill and no +/// outline is its letters, as in PowerPoint (merging text with a picture fills the letters). fn area_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area { + if let Some((letters, _)) = letters_of(doc, sel, s) { + return letters; + } let x = xfrm_of(doc, sel, s); let geo = deckcraft_render::shape_geometry(s, x.w, x.h); let a = x.affine(); let mut out: Area = vec![]; for sp in geo.paths.iter().filter(|p| p.fill != deckcraft_geom::preset::FillMode::None) { - let mut cur: Vec<[f64; 2]> = vec![]; - let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| { - if cur.len() > 2 { - out.push(std::mem::take(cur)); - } else { - cur.clear(); - } - }; - deckcraft_geom::preset_flatten(&sp.path, &mut |el| match el { - PathEl::MoveTo(p) => { - flush(&mut cur, &mut out); - let q = a * p; - cur.push([q.x, q.y]); - } - PathEl::LineTo(p) => { - let q = a * p; - cur.push([q.x, q.y]); - } - PathEl::ClosePath => flush(&mut cur, &mut out), - _ => {} - }); - flush(&mut cur, &mut out); + flatten(&sp.path, a, &mut out); } out } +/// The letters of a text box with no fill and no outline, in slide points, and their colour. +fn letters_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Option<(Area, Option)> { + use deckcraft_model::resolve; + use deckcraft_model::style::Fill; + if !matches!(s.kind, ShapeKind::Shape) || s.text.as_ref().is_none_or(|t| t.is_empty()) { + return None; + } + let ctx = super::text::ctx_for(doc, sel)?; + let none = |f: Option<&Fill>| f.is_none_or(Fill::is_none); + if !none(resolve::fill(&ctx, s).0.as_ref()) || !none(resolve::line(&ctx, s).0.fill.as_ref()) { + return None; + } + let (path, colour) = deckcraft_render::text_outline(&ctx, s, xfrm_of(doc, sel, s).affine()); + let mut out: Area = vec![]; + flatten(&path, deckcraft_geom::Affine::IDENTITY, &mut out); + (!out.is_empty()).then_some((out, colour)) +} + +/// Append the closed polygons of `path` (mapped by `a`) to `out`. +fn flatten(path: &deckcraft_geom::BezPath, a: deckcraft_geom::Affine, out: &mut Area) { + let mut cur: Vec<[f64; 2]> = vec![]; + let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| { + if cur.len() > 2 { + out.push(std::mem::take(cur)); + } else { + cur.clear(); + } + }; + deckcraft_geom::preset_flatten(path, &mut |el| match el { + PathEl::MoveTo(p) => { + flush(&mut cur, out); + let q = a * p; + cur.push([q.x, q.y]); + } + PathEl::LineTo(p) => { + let q = a * p; + cur.push([q.x, q.y]); + } + PathEl::ClosePath => flush(&mut cur, out), + _ => {} + }); + flush(&mut cur, out); +} + +/// Keep a picture where it was on the slide when its box becomes `to`: crop it to the new box. +/// Only for upright, unflipped pictures stretched over their box. +fn recrop(fill: &mut deckcraft_model::style::PictureFill, from: &Xfrm, to: &Xfrm) { + use deckcraft_model::style::PictureMode; + if from.rot != 0.0 || from.flip_h || from.flip_v || !matches!(fill.mode, PictureMode::Stretch { fill_rect } if fill_rect == [0.0; 4]) { + return; + } + let [l, t, r, b] = fill.crop; + let (fw, fh) = (1.0 - l - r, 1.0 - t - b); + if !(fw > 1e-9 && fh > 1e-9 && from.w > 0.0 && from.h > 0.0) { + return; + } + // The whole image in slide points. + let (iw, ih) = (from.w / fw, from.h / fh); + let (ix, iy) = (from.x - l * iw, from.y - t * ih); + let c = [(to.x - ix) / iw, (to.y - iy) / ih, (ix + iw - to.x - to.w) / iw, (iy + ih - to.y - to.h) / ih]; + if c.iter().all(|v| v.is_finite()) { + fill.crop = c; + } +} + fn overlay(a: &Area, b: &Area, rule: OverlayRule) -> Area { if a.is_empty() && matches!(rule, OverlayRule::Union | OverlayRule::Xor) { return b.clone(); @@ -155,7 +203,17 @@ fn merge(s: &mut Session, p: &Value) -> Result { } let areas: Vec = shapes.iter().map(|x| area_of(&st.doc, &st.selection, x)).collect(); let results = merge_areas(&op, &areas); - let template = shapes[0].clone(); + let mut template = shapes[0].clone(); + let template_box = xfrm_of(&st.doc, &st.selection, &template); + // Letters merged become shapes filled with the text's colour. + let letters = letters_of(&st.doc, &st.selection, &template); + if let Some((_, colour)) = &letters { + use deckcraft_model::style::{ColorRef, Fill, Line}; + template.fill = Some(Fill::solid(ColorRef::rgb(colour.unwrap_or(deckcraft_color::Rgba::BLACK)))); + template.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + template.text_box = false; + } + let drop_text = letters.is_some() || shapes.iter().skip(1).any(|x| letters_of(&st.doc, &st.selection, x).is_some()); let mut new_ids = vec![]; s.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?; @@ -169,14 +227,17 @@ fn merge(s: &mut Session, p: &Value) -> Result { sh.xfrm = Some(x); sh.geom = geom; sh.name = format!("Freeform {}", k + 1); - // Text stays with the first piece only. - if k > 0 { + if let ShapeKind::Picture { fill } = &mut sh.kind { + recrop(fill, &template_box, &x); + } + // Text stays with the first piece only, and not at all once it became outlines. + if k > 0 || drop_text { sh.text = None; } made.push(sh); } for sh in &mut made { - sh.id = ShapeId(doc.alloc_id()); + sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad("shape.merge", NO_ID_SPACE))?); new_ids.push(sh.id); } let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?; @@ -216,4 +277,60 @@ mod tests { // Disjoint intersect is empty. assert!(merge_areas("intersect", &[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)]).is_empty()); } + + fn picture_and_text(s: &mut Session) -> (ShapeId, ShapeId) { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(40, 20, image::Rgba([0, 128, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("png"); + let _ = s.execute("slide.new", &json!({"layout": "blank"})); + let r = s + .execute("insert.picture", &json!({"name": "a.png", "data": base64_encode(&png), "rect": [100.0, 100.0, 400.0, 200.0]})) + .expect("picture"); + let pic = ShapeId(r["id"].as_u64().expect("id") as u32); + let r = s.execute("insert.textBox", &json!({"rect": [150.0, 120.0, 300.0, 100.0], "text": "HI"})).expect("text box"); + let text = ShapeId(r["id"].as_u64().expect("id") as u32); + (pic, text) + } + + #[test] + fn a_picture_intersected_with_text_fills_the_letters() { + // #653: the letters didn't change and the picture vanished (the text box counted as its + // whole box, and the merge took the text's outline but kept the box's formatting). + let mut s = Session::with_new(); + let (pic, text) = picture_and_text(&mut s); + let st = s.doc().expect("doc"); + let letters = letters_of(&st.doc, &st.selection, st.shape(text).expect("text")).expect("the text box is its letters").0; + let box_area = 300.0 * 100.0; + assert!(area_size(&letters) > 10.0 && area_size(&letters) < box_area * 0.5, "{}", area_size(&letters)); + let r = s.execute("shape.merge", &json!({"op": "intersect", "ids": [pic.0, text.0]})).expect("merge"); + let ids = r["ids"].as_array().expect("ids"); + assert_eq!(ids.len(), 1); + let st = s.doc().expect("doc"); + let out = st.shape(ShapeId(ids[0].as_u64().expect("id") as u32)).expect("result").clone(); + let ShapeKind::Picture { fill } = &out.kind else { panic!("the picture fills the letters: {:?}", out.kind) }; + assert!(out.text.is_none(), "the letters are outlines now"); + // The result is the letters' outline, well inside the text box. + let x = out.xfrm.expect("box"); + assert!(x.x >= 150.0 && x.y >= 120.0 && x.x + x.w <= 450.0 && x.y + x.h <= 220.0, "{x:?}"); + let merged = area_of(&st.doc, &st.selection, &out); + assert!((area_size(&merged) - area_size(&letters)).abs() < area_size(&letters) * 0.05, "{} vs {}", area_size(&merged), area_size(&letters)); + // The picture stays where it was on the slide: cropped to the new box. + let [l, t, rr, b] = fill.crop; + let iw = x.w / (1.0 - l - rr); + let ih = x.h / (1.0 - t - b); + assert!((x.x - l * iw - 100.0).abs() < 1e-6 && (iw - 400.0).abs() < 1e-6, "{:?}", fill.crop); + assert!((x.y - t * ih - 100.0).abs() < 1e-6 && (ih - 200.0).abs() < 1e-6, "{:?}", fill.crop); + } + + #[test] + fn text_merged_first_becomes_shapes_in_its_colour() { + let mut s = Session::with_new(); + let (pic, text) = picture_and_text(&mut s); + let r = s.execute("shape.merge", &json!({"op": "union", "ids": [text.0, pic.0]})).expect("merge"); + let st = s.doc().expect("doc"); + let out = st.shape(ShapeId(r["ids"][0].as_u64().expect("id") as u32)).expect("result"); + assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none()); + assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..5695cc24 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,21 +2,34 @@ pub mod animation; pub mod arrange; +pub mod artistic; +pub mod background; +pub mod compress; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; +pub mod ideas; pub mod insert; pub mod inspect; pub mod media; pub mod merge; +pub mod picture_style; +pub mod points; +pub mod recolor; +pub mod reuse; pub mod review; +mod richclip; pub mod selection; pub mod shape; pub mod slide; +pub mod smartart; pub mod table; pub mod text; +pub mod three_d; pub mod transition; +pub mod zoom; use deckcraft_geom::Xfrm; use deckcraft_model::ShapeId; @@ -34,6 +47,9 @@ pub struct CommandSpec { /// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu). pub menu: &'static [&'static str], pub shortcut: Option<&'static str>, + /// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks + /// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values + /// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer. pub params: &'static str, pub enabled: Enabled, pub run: Run, @@ -54,6 +70,28 @@ pub struct CommandInfo { } impl CommandSpec { + /// Holds because `Session::execute` skips the selection check when `id` or `ids` is given. + pub fn acts_on(&self) -> Option { + let keys = param_keys(self.params).unwrap_or_default(); + let named = if keys.contains(&"ids") { + Some("the shapes in `ids`") + } else if keys.contains(&"id") { + Some("the shape in `id`") + } else { + None + }; + let reason = (self.enabled)(&Session::with_new()).err()?; + Some(match (reason.as_str(), named) { + (SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(), + (NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."), + (NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(), + (SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."), + (SELECT_TWO, None) => "Acts on two or more selected shapes.".into(), + (NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(), + _ => return None, + }) + } + pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { @@ -80,6 +118,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> { } pub const NOTHING_SELECTED: &str = "nothing selected"; pub const SELECT_TWO: &str = "select two or more objects"; +pub const NO_TEXT_POINT: &str = "no text insertion point"; +pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape"; pub fn has_selection(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } @@ -90,17 +130,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> { } pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) } } -/// Text editing or shapes with text selected (formatting commands apply to either). +/// Text editing or selected shapes (formatting commands apply to either). pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { - Ok(()) - } else { - Err("select text or a shape".into()) - } + if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) } } + pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } @@ -128,20 +165,31 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(file::specs()); v.extend(edit::specs()); v.extend(slide::specs()); + v.extend(reuse::specs()); v.extend(insert::specs()); + v.extend(smartart::specs()); v.extend(shape::specs()); + v.extend(background::specs()); + v.extend(picture_style::specs()); + v.extend(recolor::specs()); v.extend(arrange::specs()); v.extend(selection::specs()); v.extend(merge::specs()); + v.extend(points::specs()); v.extend(text::specs()); v.extend(format::specs()); v.extend(design::specs()); + v.extend(ideas::specs()); v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); + v.extend(artistic::specs()); + v.extend(zoom::specs()); + v.extend(compress::specs()); v }) } @@ -150,8 +198,105 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +pub(crate) fn param_keys(doc: &str) -> std::result::Result, String> { + let mut keys = vec![]; + for p in parse_params(doc)? { + if !keys.contains(&p.name) { + keys.push(p.name); + } + } + Ok(keys) +} + +#[derive(Debug, PartialEq)] +pub struct Param<'a> { + pub name: &'a str, + pub optional: bool, + /// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`). + pub about: &'a str, + /// An alternative to the parameter before it in the same object (`color? | none?`). + pub or: bool, + /// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1. + pub object: usize, +} + +/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset, +/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result +/// and is skipped. Errs where the doc strays from that grammar. +pub fn parse_params(doc: &str) -> std::result::Result>, String> { + let doc = doc.split('→').next().unwrap_or_default(); + let mut params: Vec = vec![]; + let mut open: Vec = vec![]; + let mut expect_key = false; + let mut or = false; + let mut objects: usize = 0; + let mut about_from: Option = None; + let mut chars = doc.char_indices().peekable(); + let mut prev = ' '; + fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option, to: usize) { + if let (Some(f), Some(p)) = (from.take(), params.last_mut()) { + p.about = doc.get(f..to).unwrap_or_default().trim(); + } + } + while let Some((i, c)) = chars.next() { + let in_object = open == ['{']; + match c { + '{' | '[' | '(' => { + open.push(c); + if open == ['{'] { + expect_key = true; + objects += 1; + or = false; + } + } + '}' | ']' | ')' => { + if in_object { + end_about(doc, &mut params, &mut about_from, i); + } + open.pop(); + } + ',' if in_object => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + } + '|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + or = true; + } + c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => { + let mut end = i + c.len_utf8(); + while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') { + end = j + n.len_utf8(); + chars.next(); + } + let after = doc.get(end..).unwrap_or_default(); + let rest = after.trim_start_matches('?').trim_start(); + let at = doc.len() - rest.len(); + about_from = match rest.chars().next() { + Some(':') => Some(at + 1), + Some('(') => Some(at), + Some(',' | '}' | '|') => None, + _ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::())), + }; + params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) }); + or = false; + expect_key = false; + } + c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])), + _ => {} + } + prev = c; + } + Ok(params) +} + // ---------- param helpers ---------- +/// The deck has used every object id (a hostile file can arrive that way): it cannot grow, and +/// growing would duplicate ids (#407). +pub(crate) const NO_ID_SPACE: &str = "the presentation has used every object id and can hold no more"; + pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -173,8 +318,17 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { pub(crate) fn usize_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()) } -pub(crate) fn id_param(p: &Value, key: &str) -> Option { - p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).map(ShapeId) +/// 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> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(|v| Some(ShapeId(v))) + .ok_or_else(|| bad(cmd, format!("`{key}` is not a valid shape id: {v}"))), + } } pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(ShapeId).collect()) @@ -198,7 +352,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result> { { return Ok(ids); } - if let Some(id) = id_param(p, "id") { + if let Some(id) = id_param(p, "id", "shape")? { return Ok(vec![id]); } Ok(s.doc()?.selection.shapes.clone()) @@ -274,6 +428,9 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)), crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => { + crate::page_master(doc, t).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)) + } crate::Target::Layout { master, layout } => { doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape))) } @@ -327,10 +484,83 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => crate::page_master(doc, t).map(|m| &m.shapes), crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes), } } +/// The command palette runs a command with `{}`. True when the params doc names a value that +/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it. +/// A name ending in `?` is optional. Text after `→` is the result, not an input. +pub fn needs_arguments(params: &str) -> bool { + let input = params.split('→').next().unwrap_or(params); + let Some(start) = input.find('{') else { return false }; + let inside = input.get(start + 1..).unwrap_or(""); + let inside = inside.split('}').next().unwrap_or(inside); + split_fields(inside).iter().any(|f| field_requires(f.trim())) +} + +fn split_fields(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut depth = 0i32; + let mut quote = false; + for (i, c) in s.char_indices() { + if quote { + if c == '"' { + quote = false; + } + continue; + } + match c { + '"' => quote = true, + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + out.push(s.get(start..i).unwrap_or("")); + start = i + c.len_utf8(); + } + _ => {} + } + } + out.push(s.get(start..).unwrap_or("")); + out +} + +/// A field needs a value unless every alternative (`path? | data`) is an optional parameter. +fn field_requires(field: &str) -> bool { + if field.is_empty() { + return false; + } + let alts: Vec<&str> = if field.contains(" | ") { field.split(" | ").map(str::trim).filter(|s| !s.is_empty()).collect() } else { vec![field] }; + let mut saw = false; + let mut all_optional = true; + for alt in alts { + let Some(optional) = param_optional(alt) else { continue }; + saw = true; + all_optional &= optional; + } + saw && !all_optional +} + +/// `Some(true)` when `alt` starts with an optional parameter name, `Some(false)` when required. +fn param_optional(alt: &str) -> Option { + let alt = alt.trim(); + let name_end = alt.find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(alt.len()); + let name = alt.get(..name_end).unwrap_or(""); + if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) { + return None; + } + let rest = alt.get(name_end..).unwrap_or("").trim_start(); + if rest.starts_with('?') { + Some(true) + } else if rest.is_empty() || rest.starts_with(':') { + Some(false) + } else { + None + } +} + /// Base64 (standard alphabet) for binary payloads in JSON params and results. pub fn base64_encode(data: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -373,3 +603,32 @@ pub fn base64_decode(s: &str) -> Option> { } Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_palette_skips_a_command_that_needs_a_value() { + assert!(needs_arguments("{path}")); + assert!(needs_arguments("{path, format: png|jpeg|pptx, slide?: index}")); + assert!(needs_arguments("{path? | data: base64, name?, rect?}")); + assert!(needs_arguments("{rows, cols, rect?}")); + assert!(needs_arguments("{axis: horizontal|vertical, ids?}")); + assert!(needs_arguments("{percent}")); + assert!(!needs_arguments("{}")); + assert!(!needs_arguments("{on?: bool}")); + assert!(!needs_arguments("{theme?: name, size?: [w,h] pt, blank?: bool}")); + assert!(!needs_arguments("{uid?: one entry, else all}")); + assert!(!needs_arguments("{locked?: bool (default: toggle), ids?}")); + assert!(!needs_arguments("{} → [{name, label, category}]")); + assert!(!needs_arguments("{title?, subject?} → properties")); + assert!(!needs_arguments("{slides?: [index] | \"1-3,5\", includeHidden?}")); + let export = command_specs().iter().find(|c| c.id == "file.export").expect("export"); + let save = command_specs().iter().find(|c| c.id == "file.save").expect("save"); + let audio = command_specs().iter().find(|c| c.id == "insert.audio").expect("audio"); + assert!(needs_arguments(export.params)); + assert!(!needs_arguments(save.params)); + assert!(needs_arguments(audio.params)); + } +} diff --git a/crates/engine/src/cmd/picture_style.rs b/crates/engine/src/cmd/picture_style.rs new file mode 100644 index 00000000..a6a7380f --- /dev/null +++ b/crates/engine/src/cmd/picture_style.rs @@ -0,0 +1,158 @@ +//! Picture Format ▸ Picture Styles: PowerPoint's quick styles for pictures, each a frame (shape, +//! outline) and effects applied as one step, replacing the previous style. + +use deckcraft_color::{ColorTransform, Rgba}; +use deckcraft_model::style::{Effects, Reflection, Shadow}; +use deckcraft_model::{ColorRef, Geom, Line, Shape}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "picture.style", + "Picture Styles", + ["Picture Format", "Picture Styles"], + None, + "{style: simpleFrameWhite|dropShadowRectangle|softEdgeOval|… | none (see picture.styles), ids?}", + has_selection, + style + ), + cmd!(query "picture.styles", "Picture Style Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!( + STYLES.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>() + ))), + ] +} + +/// (id, label), in PowerPoint's gallery order. +pub const STYLES: [(&str, &str); 24] = [ + ("simpleFrameWhite", "Simple Frame, White"), + ("beveledMatteWhite", "Beveled Matte, White"), + ("metalFrame", "Metal Frame"), + ("dropShadowRectangle", "Drop Shadow Rectangle"), + ("roundedDiagonalCornerWhite", "Rounded Diagonal Corner, White"), + ("snipDiagonalCornerWhite", "Snip Diagonal Corner, White"), + ("moderateFrameWhite", "Moderate Frame, White"), + ("rotatedWhite", "Rotated, White"), + ("perspectiveShadowWhite", "Perspective Shadow, White"), + ("softEdgeRectangle", "Soft Edge Rectangle"), + ("doubleFrameBlack", "Double Frame, Black"), + ("thickMatteBlack", "Thick Matte, Black"), + ("simpleFrameBlack", "Simple Frame, Black"), + ("beveledOvalBlack", "Beveled Oval, Black"), + ("compoundFrameBlack", "Compound Frame, Black"), + ("centerShadowRectangle", "Center Shadow Rectangle"), + ("softEdgeOval", "Soft Edge Oval"), + ("reflectedRoundedRectangle", "Reflected Rounded Rectangle"), + ("reflectedBevelWhite", "Reflected Bevel, White"), + ("metalRoundedRectangle", "Metal Rounded Rectangle"), + ("moderateFrameBlack", "Moderate Frame, Black"), + ("bevelRectangle", "Bevel Rectangle"), + ("bevelPerspective", "Bevel Perspective"), + ("metalOval", "Metal Oval"), +]; + +/// The look of a style: geometry, outline (colour, width) and effects. +pub struct Look { + pub preset: &'static str, + pub frame: Option<(Rgba, f64)>, + pub shadow: Option<&'static str>, + pub soft_edge: Option, + pub reflection: bool, + pub bevel: Option<&'static str>, + pub rot: Option, +} + +pub fn look(id: &str) -> Option { + let white = Rgba::rgb(0xFF, 0xFF, 0xFF); + let black = Rgba::rgb(0, 0, 0); + let silver = Rgba::rgb(0xC0, 0xC0, 0xC0); + let l = |preset, frame, shadow| Look { preset, frame, shadow, soft_edge: None, reflection: false, bevel: None, rot: None }; + Some(match id { + "simpleFrameWhite" => l("rect", Some((white, 7.0)), Some("soft")), + "beveledMatteWhite" => Look { bevel: Some("circle"), ..l("rect", Some((white, 6.0)), Some("soft")) }, + "metalFrame" => Look { bevel: Some("angle"), ..l("rect", Some((silver, 7.0)), Some("soft")) }, + "dropShadowRectangle" => l("rect", None, Some("drop")), + "roundedDiagonalCornerWhite" => l("round2DiagRect", Some((white, 6.0)), Some("soft")), + "snipDiagonalCornerWhite" => l("snip2DiagRect", Some((white, 6.0)), Some("soft")), + "moderateFrameWhite" => l("rect", Some((white, 4.5)), Some("soft")), + "rotatedWhite" => Look { rot: Some(356.0), ..l("rect", Some((white, 6.0)), Some("soft")) }, + "perspectiveShadowWhite" => l("rect", Some((white, 4.0)), Some("perspective")), + "softEdgeRectangle" => Look { soft_edge: Some(10.0), ..l("rect", None, None) }, + "doubleFrameBlack" => l("rect", Some((black, 6.0)), None), + "thickMatteBlack" => l("rect", Some((black, 10.0)), Some("soft")), + "simpleFrameBlack" => l("rect", Some((black, 5.0)), None), + "beveledOvalBlack" => Look { bevel: Some("circle"), ..l("ellipse", Some((black, 4.0)), Some("soft")) }, + "compoundFrameBlack" => l("rect", Some((black, 4.5)), None), + "centerShadowRectangle" => l("rect", None, Some("center")), + "softEdgeOval" => Look { soft_edge: Some(12.0), ..l("ellipse", None, None) }, + "reflectedRoundedRectangle" => Look { reflection: true, ..l("roundRect", None, None) }, + "reflectedBevelWhite" => Look { reflection: true, bevel: Some("circle"), ..l("rect", Some((white, 4.0)), None) }, + "metalRoundedRectangle" => Look { bevel: Some("angle"), ..l("roundRect", Some((silver, 6.0)), Some("soft")) }, + "moderateFrameBlack" => l("rect", Some((black, 4.5)), Some("soft")), + "bevelRectangle" => Look { bevel: Some("angle"), ..l("rect", None, Some("soft")) }, + "bevelPerspective" => Look { bevel: Some("relaxedInset"), ..l("rect", None, Some("perspective")) }, + "metalOval" => Look { bevel: Some("angle"), ..l("ellipse", Some((silver, 6.0)), Some("soft")) }, + _ => return None, + }) +} + +fn shadow(kind: &str) -> Shadow { + let base = Shadow { + color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)), + blur: 4.0, + dist: 3.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "tl".into(), + rotate_with_shape: false, + }; + match kind { + "drop" => Shadow { blur: 12.0, dist: 6.0, dir: 90.0, ..base }, + "center" => Shadow { blur: 16.0, dist: 0.0, ..base }, + "perspective" => Shadow { sy: 0.23, kx: -20.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base }, + _ => base, + } +} + +/// Apply style `id` (or clear the style with `none`) to `sh`. +pub fn apply(sh: &mut Shape, id: &str) -> bool { + if id == "none" { + sh.geom = Geom::preset("rect"); + sh.line = None; + sh.effects = None; + return true; + } + let Some(lk) = look(id) else { return false }; + sh.geom = Geom::preset(lk.preset); + sh.line = Some(lk.frame.map_or_else(Line::none, |(c, w)| Line::solid(ColorRef::rgb(c), w))); + let e = Effects { + outer_shadow: lk.shadow.map(shadow), + soft_edge: lk.soft_edge, + reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), + bevel: lk.bevel.map(str::to_string), + ..Default::default() + }; + sh.effects = (!e.is_empty()).then_some(e); + if let (Some(r), Some(x)) = (lk.rot, sh.xfrm.as_mut()) { + x.rot = r; + } + true +} + +fn style(s: &mut Session, p: &Value) -> Result { + let id = str_param(p, "style").ok_or_else(|| bad("picture.style", "missing `style`"))?.to_string(); + if id != "none" && look(&id).is_none() { + return Err(bad("picture.style", format!("unknown style `{id}`; see picture.styles"))); + } + edit_shapes(s, p, "picture.style", |sh| { + apply(sh, &id); + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs new file mode 100644 index 00000000..e8385d39 --- /dev/null +++ b/crates/engine/src/cmd/points.rs @@ -0,0 +1,786 @@ +//! Edit Points (Shape Format ▸ Edit Shape ▸ Edit Points): turn a shape into a freeform and move, +//! add or delete the vertices of its outline. +//! +//! While points are edited every custom path of the shape is kept in shape-local points (its +//! `w`/`h` equal the shape's size), so a vertex's path coordinates are its offset in the shape. +//! After each change the shape's box is fitted around the outline again, keeping it in place on +//! the slide (rotation and flips included). + +use deckcraft_geom::{PathEl, Point, Xfrm}; +use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + noundo "shape.editPoints", + "Edit Points", + ["Shape Format", "Insert Shapes", "Edit Shape"], + None, + "{on?: bool (default: toggle), id?} — enter or leave Edit Points for one selected shape; entering turns a preset into a freeform", + has_selection, + edit_points + ), + cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points), + cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point), + cmd!( + "shape.addPoint", + "Add Point", + [], + None, + "{path?: 0, segment: index of the vertex ending it, t?: 0..1 (0.5), id?}", + has_selection, + add_point + ), + cmd!( + "shape.pointType", + "Point Type", + [], + None, + "{path?: 0, index, type: smooth|straight|corner, id?} — smooth: handles in line and equal; straight: in line; corner: independent", + has_selection, + point_type + ), + cmd!( + "shape.moveHandle", + "Move Handle", + [], + None, + "{path?: 0, index, side: in|out, x, y: slide pt, type?: smooth|straight|corner (default: read from the handles), id?}", + has_selection, + move_handle + ), + cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point), + ] +} + +/// One path command, in path units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Seg { + M(Point), + L(Point), + C(Point, Point, Point), + Q(Point, Point), + Z, +} + +impl Seg { + pub fn end(&self) -> Option { + match self { + Seg::M(p) | Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p) => Some(*p), + Seg::Z => None, + } + } + fn points_mut(&mut self) -> Vec<&mut Point> { + match self { + Seg::M(p) | Seg::L(p) => vec![p], + Seg::C(a, b, p) => vec![a, b, p], + Seg::Q(a, p) => vec![a, p], + Seg::Z => vec![], + } + } +} + +/// Parses path data (`M x y L x y C … Q … Z`); malformed commands are skipped. +pub fn parse(d: &str) -> Vec { + let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect(); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); + let pt = |i: usize| Some(Point::new(num(i)?, num(i + 1)?)); + let mut out = Vec::new(); + let mut i = 0; + while i < toks.len() && out.len() < 100_000 { + let (seg, n) = match toks.get(i).copied().unwrap_or("") { + "M" => (pt(i + 1).map(Seg::M), 3), + "L" => (pt(i + 1).map(Seg::L), 3), + "C" => ((|| Some(Seg::C(pt(i + 1)?, pt(i + 3)?, pt(i + 5)?)))(), 7), + "Q" => ((|| Some(Seg::Q(pt(i + 1)?, pt(i + 3)?)))(), 5), + "Z" => (Some(Seg::Z), 1), + _ => (None, 1), + }; + // Make a closing edge explicit, so it is a segment that can take points. + if matches!(seg, Some(Seg::Z)) { + let start = out.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }); + let end = out.last().and_then(Seg::end); + if let (Some(a), Some(b)) = (start, end) + && (a - b).hypot() > 1e-6 + { + out.push(Seg::L(a)); + } + } + if let Some(s) = seg { + // A path starts with a move. + let s = match (out.is_empty(), s) { + (true, Seg::L(p)) => Seg::M(p), + (true, Seg::C(..) | Seg::Q(..) | Seg::Z) => { + i += n; + continue; + } + (_, s) => s, + }; + out.push(s); + } + i += n; + } + out +} + +/// Path data for `segs`. +pub fn write(segs: &[Seg]) -> String { + let f = |v: f64| { + let s = format!("{:.3}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + }; + let p = |q: &Point| format!("{} {}", f(q.x), f(q.y)); + segs.iter() + .map(|s| match s { + Seg::M(a) => format!("M {}", p(a)), + Seg::L(a) => format!("L {}", p(a)), + Seg::C(a, b, c) => format!("C {} {} {}", p(a), p(b), p(c)), + Seg::Q(a, b) => format!("Q {} {}", p(a), p(b)), + Seg::Z => "Z".into(), + }) + .collect::>() + .join(" ") +} + +/// Whether segment `i` ends back on its sub-path's start right before a `Z` (the closing edge of +/// a closed outline). Its end is the same vertex as the start, not a vertex of its own. +fn closes_onto_start(segs: &[Seg], i: usize) -> bool { + if !matches!(segs.get(i + 1), Some(Seg::Z)) { + return false; + } + let start = segs.get(..=i).and_then(|s| s.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None })); + match (start, segs.get(i).and_then(Seg::end)) { + (Some(a), Some(b)) => !matches!(segs.get(i), Some(Seg::M(_))) && (a - b).hypot() < 1e-6, + _ => false, + } +} + +/// The segments whose end is a vertex, in order. +pub fn vertices(segs: &[Seg]) -> Vec { + (0..segs.len()).filter(|i| segs.get(*i).and_then(Seg::end).is_some() && !closes_onto_start(segs, *i)).collect() +} + +fn shift(segs: &mut [Seg], i: usize, which: &[usize], d: deckcraft_geom::Vec2) { + if let Some(s) = segs.get_mut(i) { + let mut pts = s.points_mut(); + for w in which { + if let Some(p) = pts.get_mut(*w) { + **p += d; + } + } + } +} + +/// The indices of the points of segment `i` that sit on its end vertex (the end and, for a +/// cubic, its incoming control point). +fn end_points(seg: Option<&Seg>) -> &'static [usize] { + match seg { + Some(Seg::C(..)) => &[1, 2], + Some(Seg::Q(..)) => &[1], + Some(Seg::M(_) | Seg::L(_)) => &[0], + _ => &[], + } +} + +/// Moves the vertex ending segment `si` by `d`, with the control points attached to it. +fn move_vertex(segs: &mut [Seg], si: usize, d: deckcraft_geom::Vec2) { + // A closed sub-path's start is also its closing edge's end. + let close = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + None + }; + let own = end_points(segs.get(si)); + shift(segs, si, own, d); + // The outgoing curve's first control point follows the vertex. + if matches!(segs.get(si + 1), Some(Seg::C(..))) { + shift(segs, si + 1, &[0], d); + } + if let Some(j) = close { + let w = end_points(segs.get(j)); + shift(segs, j, w, d); + } +} + +/// The point where segment `si` starts. +fn seg_start(segs: &[Seg], si: usize) -> Option { + let prev = segs.get(..si)?; + match prev.last()? { + Seg::Z => prev.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }), + s => s.end(), + } +} + +/// Splits segment `si` at `t`, adding a vertex there. Returns false for a move or close. +fn split(segs: &mut Vec, si: usize, t: f64) -> bool { + let Some(p0) = seg_start(segs, si) else { return false }; + let lerp = |a: Point, b: Point| a.lerp(b, t); + let (a, b) = match segs.get(si).copied() { + Some(Seg::L(p1)) => (Seg::L(lerp(p0, p1)), Seg::L(p1)), + Some(Seg::Q(c, p1)) => { + let (m0, m1) = (lerp(p0, c), lerp(c, p1)); + let m = m0.lerp(m1, t); + (Seg::Q(m0, m), Seg::Q(m1, p1)) + } + Some(Seg::C(c1, c2, p1)) => { + let (a1, a2, a3) = (lerp(p0, c1), lerp(c1, c2), lerp(c2, p1)); + let (b1, b2) = (a1.lerp(a2, t), a2.lerp(a3, t)); + let m = b1.lerp(b2, t); + (Seg::C(a1, b1, m), Seg::C(b2, a3, p1)) + } + _ => return false, + }; + if let Some(s) = segs.get_mut(si) { + *s = a; + } + segs.insert(si + 1, b); + true +} + +/// Removes the vertex ending segment `si`. A sub-path keeps at least two vertices. +fn remove_vertex(segs: &mut Vec, si: usize) -> bool { + // The vertices of `si`'s sub-path. + let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0); + let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len()); + let count = vertices(segs).iter().filter(|v| (from..to).contains(*v)).count(); + if count <= 2 { + return false; + } + if matches!(segs.get(si), Some(Seg::M(_))) { + // The next vertex becomes the start; a closed outline's closing edge now ends there. + let Some(next) = segs.get(si + 1).and_then(Seg::end) else { return false }; + segs.remove(si + 1); + if let Some(s) = segs.get_mut(si) { + *s = Seg::M(next); + } + let close = (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| matches!(segs.get(j + 1), Some(Seg::Z))); + if let Some(j) = close + && let Some(Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p)) = segs.get_mut(j) + { + *p = next; + } + return true; + } + // Merge with the next segment: it now starts where the removed vertex's segment started. + match (segs.get(si).copied(), segs.get(si + 1).copied()) { + (Some(Seg::C(c1, _, _)), Some(Seg::C(_, c2, p))) => { + segs.remove(si + 1); + if let Some(s) = segs.get_mut(si) { + *s = Seg::C(c1, c2, p); + } + } + _ => { + segs.remove(si); + } + } + true +} + +/// Custom paths of a preset shape, in shape-local points. +fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec { + let geo = deckcraft_render::shape_geometry(sh, w, h); + geo.paths + .iter() + .map(|sp| { + let segs: Vec = sp + .path + .elements() + .iter() + .map(|e| match *e { + PathEl::MoveTo(p) => Seg::M(p), + PathEl::LineTo(p) => Seg::L(p), + PathEl::QuadTo(a, p) => Seg::Q(a, p), + PathEl::CurveTo(a, b, p) => Seg::C(a, b, p), + PathEl::ClosePath => Seg::Z, + }) + .collect(); + CustomPath { w, h, d: write(&segs), fill: sp.fill, stroke: sp.stroke } + }) + .collect() +} + +/// Rescales every path of a custom geometry to shape-local points. +fn normalize(paths: &mut [CustomPath], w: f64, h: f64) { + for p in paths { + let (sx, sy) = (if p.w > 0.0 { w / p.w } else { 1.0 }, if p.h > 0.0 { h / p.h } else { 1.0 }); + if (sx - 1.0).abs() > 1e-12 || (sy - 1.0).abs() > 1e-12 { + let mut segs = parse(&p.d); + for s in &mut segs { + for q in s.points_mut() { + *q = Point::new(q.x * sx, q.y * sy); + } + } + p.d = write(&segs); + } + p.w = w; + p.h = h; + } +} + +/// Fits the box around the outline, keeping the drawing where it is on the slide. +fn refit(sh: &mut Shape) { + let (Some(x), Geom::Custom { paths }) = (sh.xfrm, &mut sh.geom) else { return }; + let mut all: Vec> = paths.iter().map(|p| parse(&p.d)).collect(); + let mut bb: Option = None; + for s in all.iter_mut().flatten() { + for q in s.points_mut() { + bb = Some(bb.map_or(deckcraft_geom::Rect::from_points(*q, *q), |b| b.union_pt(*q))); + } + } + let Some(bb) = bb else { return }; + let (w, h) = (bb.width().max(1.0), bb.height().max(1.0)); + if bb.x0.abs() < 1e-6 && bb.y0.abs() < 1e-6 && (w - x.w).abs() < 1e-6 && (h - x.h).abs() < 1e-6 { + return; + } + let c = x.affine() * Point::new(bb.x0 + w / 2.0, bb.y0 + h / 2.0); + let off = deckcraft_geom::Vec2::new(bb.x0, bb.y0); + for (p, segs) in paths.iter_mut().zip(&mut all) { + for s in segs.iter_mut() { + for q in s.points_mut() { + *q -= off; + } + } + p.d = write(segs); + p.w = w; + p.h = h; + } + sh.xfrm = Some(Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..x }); +} + +/// The one shape whose points are edited: `id`, or the single selected shape. +fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let id = match p.get("id").and_then(Value::as_u64) { + Some(i) => ShapeId(i as u32), + None => match st.selection.shapes.as_slice() { + [one] => *one, + _ => return Err(bad(cmd, "select one shape")), + }, + }; + let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?; + if !st.shapes().iter().any(|t| t.id == id) { + return Err(bad(cmd, "a shape in a group can't have its points edited; ungroup it first")); + } + if !matches!(sh.kind, ShapeKind::Shape) || sh.is_line() || sh.xfrm.is_none() { + return Err(bad(cmd, "only a shape with an outline has points to edit")); + } + if sh.locked { + return Err(bad(cmd, "the shape is locked")); + } + Ok(id) +} + +/// Makes `id` a freeform in shape-local points (the state Edit Points works in). +pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> { + let st = s.doc()?; + let Some(sh) = st.shape(id) else { return Ok(()) }; + let Some(x) = sh.xfrm else { return Ok(()) }; + let ready = matches!(&sh.geom, Geom::Custom { paths } if paths.iter().all(|p| p.w == x.w && p.h == x.h)); + if ready { + return Ok(()); + } + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.editPoints", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + match &mut sh.geom { + Geom::Custom { paths } => normalize(paths, x.w, x.h), + Geom::Preset { .. } => { + let paths = to_custom(sh, x.w, x.h); + sh.geom = Geom::Custom { paths }; + } + } + } + Ok(()) + }) +} + +fn edit_points(s: &mut Session, p: &Value) -> Result { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none()); + s.tool.edit_vertex = None; + if !on { + s.tool.edit_points = None; + return Ok(json!({"editing": Value::Null})); + } + let id = target(s, p, "shape.editPoints")?; + prepare(s, id)?; + s.tool.edit_points = Some(id); + Ok(json!({"editing": id.0})) +} + +/// Vertices of every path of `sh` in slide points. +pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape) -> Vec> { + let Geom::Custom { paths } = &sh.geom else { return vec![] }; + let x = xfrm_of(doc, sel, sh); + let a = x.affine(); + paths + .iter() + .map(|p| { + let (sx, sy) = (if p.w > 0.0 { x.w / p.w } else { 1.0 }, if p.h > 0.0 { x.h / p.h } else { 1.0 }); + let segs = parse(&p.d); + vertices(&segs).iter().filter_map(|i| segs.get(*i).and_then(Seg::end)).map(|q| a * Point::new(q.x * sx, q.y * sy)).collect() + }) + .collect() +} + +fn points(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = match p.get("id").and_then(Value::as_u64) { + Some(i) => ShapeId(i as u32), + None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?, + }; + let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?; + let paths: Vec> = + 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> = match &sh.geom { + Geom::Custom { paths } => paths + .iter() + .map(|cp| { + let segs = parse(&cp.d); + vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect() + }) + .collect(), + Geom::Preset { .. } => vec![], + }; + Ok(json!({"id": id.0, "paths": paths, "types": types})) +} + +/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box. +/// `f` gets the segment indices of the vertices and returns whether it changed anything. +pub(crate) fn edit_path( + s: &mut Session, + id: ShapeId, + path: usize, + cmd: &'static str, + f: impl Fn(&mut Vec, &[usize], &Xfrm) -> Result, +) -> Result { + prepare(s, id)?; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad(cmd, "no such shape"))?; + let x = sh.xfrm.ok_or_else(|| bad(cmd, "the shape has no position"))?; + let Geom::Custom { paths } = &mut sh.geom else { return Err(bad(cmd, "not a freeform")) }; + let cp = paths.get_mut(path).ok_or_else(|| bad(cmd, "no such path"))?; + let mut segs = parse(&cp.d); + let vs = vertices(&segs); + if f(&mut segs, &vs, &x)? { + cp.d = write(&segs); + refit(sh); + } + Ok(()) + })?; + ok() +} + +fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("give `{key}`"))) +} + +fn move_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.movePoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.movePoint")?; + let to = match (f64_param(p, "x"), f64_param(p, "y")) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)), + _ => return Err(bad("shape.movePoint", "give `x` and `y` in slide points")), + }; + edit_path(s, id, path, "shape.movePoint", move |segs, vs, x| { + let si = *vs.get(index).ok_or_else(|| bad("shape.movePoint", "no such point"))?; + let local = x.affine().inverse() * to; + let Some(cur) = segs.get(si).and_then(Seg::end) else { return Ok(false) }; + move_vertex(segs, si, local - cur); + Ok(true) + }) +} + +fn add_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.addPoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = index_param(p, "segment", "shape.addPoint")?; + let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99); + edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| { + let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?; + // The closing edge of a closed outline ends at the start: split it, not the start. + let si = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))? + } else { + si + }; + if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) } + }) +} + +fn delete_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.deletePoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.deletePoint")?; + edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?; + if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) } + }) +} + +/// The vertex of `sh` near slide point `p`: (path, index). +pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize)> { + for (pi, vs) in slide_vertices(doc, sel, sh).iter().enumerate() { + for (i, q) in vs.iter().enumerate() { + if (*q - p).hypot() <= tol { + return Some((pi, i)); + } + } + } + None +} + +/// The segment of `sh`'s outline near slide point `p`: (path, index of the vertex ending it, t). +pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize, f64)> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let x = xfrm_of(doc, sel, sh); + let local = x.affine().inverse() * p; + let mut best: Option<(f64, usize, usize, f64)> = None; + for (pi, cp) in paths.iter().enumerate() { + let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 }); + let segs = parse(&cp.d); + let vs = vertices(&segs); + for si in 0..segs.len() { + let Some(p0) = seg_start(&segs, si) else { continue }; + let at = |t: f64| -> Option { + let q = match segs.get(si)? { + Seg::L(p1) => p0.lerp(*p1, t), + Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t), + Seg::C(c1, c2, p1) => { + let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t)); + a.lerp(b, t).lerp(b.lerp(c, t), t) + } + _ => return None, + }; + Some(Point::new(q.x * sx, q.y * sy)) + }; + // The vertex that ends this segment (a closing edge ends on the start). + let end_vertex = if closes_onto_start(&segs, si) { + let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))); + start.and_then(|m| vs.iter().position(|v| *v == m)) + } else { + vs.iter().position(|v| *v == si) + }; + let Some(ev) = end_vertex else { continue }; + for k in 1..32 { + let t = k as f64 / 32.0; + if let Some(q) = at(t) { + let d = (q - local).hypot(); + if d <= tol && best.is_none_or(|b| d < b.0) { + best = Some((d, pi, ev, t)); + } + } + } + } + } + best.map(|(_, pi, ev, t)| (pi, ev, t)) +} + +/// Moves vertex `index` of path `path` of `id` to slide point `to` (pointer drags). +pub(crate) fn move_point_raw(s: &mut Session, id: ShapeId, path: usize, index: usize, to: Point) -> Result { + move_point(s, &json!({"id": id.0, "path": path, "index": index, "x": to.x, "y": to.y})) +} + +/// Where the control handles of the vertex ending segment `si` live: (segment, point index) for +/// the incoming and the outgoing one, when those segments are cubics. +fn handle_slots(segs: &[Seg], si: usize) -> (Option<(usize, usize)>, Option<(usize, usize)>) { + let incoming = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + Some(si) + }; + let inc = incoming.filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 1)); + let out = Some(si + 1).filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 0)); + (inc, out) +} + +fn get_pt(segs: &[Seg], slot: (usize, usize)) -> Option { + match segs.get(slot.0)? { + Seg::C(a, b, c) => [*a, *b, *c].get(slot.1).copied(), + _ => None, + } +} + +fn set_pt(segs: &mut [Seg], slot: (usize, usize), p: Point) { + if let Some(s) = segs.get_mut(slot.0) + && let Some(q) = s.points_mut().into_iter().nth(slot.1) + { + *q = p; + } +} + +/// Turns segment `j` into a cubic with the same shape. +fn to_cubic(segs: &mut [Seg], j: usize) { + let Some(p0) = seg_start(segs, j) else { return }; + let c = match segs.get(j).copied() { + Some(Seg::L(p1)) => Seg::C(p0.lerp(p1, 1.0 / 3.0), p0.lerp(p1, 2.0 / 3.0), p1), + Some(Seg::Q(c, p1)) => Seg::C(p0.lerp(c, 2.0 / 3.0), p1.lerp(c, 2.0 / 3.0), p1), + _ => return, + }; + if let Some(s) = segs.get_mut(j) { + *s = c; + } +} + +/// Sets the point type of the vertex ending segment `si`. Its segments become curves so it has +/// handles; smooth and straight points get their handles in line through the vertex. +fn set_type(segs: &mut [Seg], si: usize, kind: &str) { + let incoming = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + Some(si) + }; + if let Some(j) = incoming { + to_cubic(segs, j); + } + to_cubic(segs, si + 1); + if kind == "corner" { + return; + } + let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return }; + let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return }; + let (li, lo) = ((a - v).hypot(), (b - v).hypot()); + let mut dir = b - a; + if dir.hypot() < 1e-9 { + // Both handles on the vertex: point them along the neighbours. + let prev = seg_start(segs, hi.0).unwrap_or(v); + let next = segs.get(ho.0).and_then(Seg::end).unwrap_or(v); + dir = next - prev; + } + let len = dir.hypot(); + if len < 1e-9 { + return; + } + let u = dir / len; + let (li, lo) = if kind == "smooth" { + let m = if li + lo > 1e-9 { (li + lo) / 2.0 } else { len / 6.0 }; + (m, m) + } else { + (li, lo) + }; + set_pt(segs, hi, v - u * li); + set_pt(segs, ho, v + u * lo); +} + +/// The type a vertex's handles show: smooth (in line, equal), straight (in line) or corner. +pub fn point_kind(segs: &[Seg], si: usize) -> &'static str { + let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return "corner" }; + let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return "corner" }; + let (da, db) = (a - v, b - v); + let (la, lb) = (da.hypot(), db.hypot()); + if la < 1e-6 || lb < 1e-6 { + return "corner"; + } + let cos = da.dot(db) / (la * lb); + if cos > -0.9998 { + "corner" + } else if (la - lb).abs() <= 0.01 * la.max(lb) { + "smooth" + } else { + "straight" + } +} + +/// Moves handle `side` of the vertex ending segment `si` to `p`; the other handle follows as +/// the point's type says. +fn set_handle(segs: &mut [Seg], si: usize, out: bool, p: Point, kind: Option<&str>) -> bool { + let kind = kind.unwrap_or_else(|| point_kind(segs, si)); + let (hi, ho) = handle_slots(segs, si); + let (mine, other) = if out { (ho, hi) } else { (hi, ho) }; + let Some(mine) = mine else { return false }; + let Some(v) = segs.get(si).and_then(Seg::end) else { return false }; + set_pt(segs, mine, p); + if let Some(o) = other + && kind != "corner" + && let Some(q) = get_pt(segs, o) + { + let d = p - v; + let l = d.hypot(); + if l > 1e-9 { + let lo = if kind == "smooth" { l } else { (q - v).hypot() }; + set_pt(segs, o, v - d / l * lo); + } + } + true +} + +/// Handles of vertex `index` of path `path` of `sh` in slide points: (incoming, outgoing, vertex). +pub fn slide_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + path: usize, + index: usize, +) -> Option<(Option, Option, Point)> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let cp = paths.get(path)?; + let x = xfrm_of(doc, sel, sh); + let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 }); + let a = x.affine(); + let to = |q: Point| a * Point::new(q.x * sx, q.y * sy); + let segs = parse(&cp.d); + let si = *vertices(&segs).get(index)?; + let v = segs.get(si).and_then(Seg::end)?; + let (hi, ho) = handle_slots(&segs, si); + Some((hi.and_then(|s| get_pt(&segs, s)).map(to), ho.and_then(|s| get_pt(&segs, s)).map(to), to(v))) +} + +fn point_type(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.pointType")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.pointType")?; + let kind = match str_param(p, "type") { + Some(k @ ("smooth" | "straight" | "corner")) => k.to_string(), + _ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")), + }; + // A straight point's handles can be as long as each other; remember the choice for drags. + s.tool.vertex_kind = Some((path, index, kind.clone())); + edit_path(s, id, path, "shape.pointType", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?; + set_type(segs, si, &kind); + Ok(true) + }) +} + +fn move_handle(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.moveHandle")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.moveHandle")?; + let out = match str_param(p, "side") { + Some("out") => true, + Some("in") => false, + _ => return Err(bad("shape.moveHandle", "`side` is in or out")), + }; + let to = match (f64_param(p, "x"), f64_param(p, "y")) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)), + _ => return Err(bad("shape.moveHandle", "give `x` and `y` in slide points")), + }; + let kind = str_param(p, "type").filter(|k| matches!(*k, "smooth" | "straight" | "corner")).map(str::to_string); + edit_path(s, id, path, "shape.moveHandle", move |segs, vs, x| { + let si = *vs.get(index).ok_or_else(|| bad("shape.moveHandle", "no such point"))?; + if set_handle(segs, si, out, x.affine().inverse() * to, kind.as_deref()) { + Ok(true) + } else { + Err(bad("shape.moveHandle", "that side of the point has no handle")) + } + }) +} + +/// The type of vertex `index` of path `path` of `sh`. +pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + Some(point_kind(&segs, si)) +} + +/// `shape.moveHandle` for pointer drags. +pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result { + move_handle(s, p) +} diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs new file mode 100644 index 00000000..3ee049eb --- /dev/null +++ b/crates/engine/src/cmd/recolor.rs @@ -0,0 +1,136 @@ +//! 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 serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "picture.recolor", + "Recolor", + ["Picture Format", "Adjust", "Color"], + None, + "{preset: none|grayscale|sepia|washout|bw25|bw50|bw75|accent1Dark…accent6Dark|accent1Light…accent6Light|tx2Dark|tx2Light (see picture.recolors), ids?}", + has_selection, + recolor + ), + cmd!(query "picture.recolors", "Recolor Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!( + presets().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>() + ))), + cmd!( + "picture.transparentColor", + "Set Transparent Color", + ["Picture Format", "Adjust", "Color"], + None, + "{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)", + has_selection, + transparent_color + ), + ] +} + +const ACCENTS: [(&str, &str, SchemeSlot); 7] = [ + ("tx2", "Text color 2", SchemeSlot::Dk2), + ("accent1", "Accent color 1", SchemeSlot::Accent1), + ("accent2", "Accent color 2", SchemeSlot::Accent2), + ("accent3", "Accent color 3", SchemeSlot::Accent3), + ("accent4", "Accent color 4", SchemeSlot::Accent4), + ("accent5", "Accent color 5", SchemeSlot::Accent5), + ("accent6", "Accent color 6", SchemeSlot::Accent6), +]; + +/// (id, label), in PowerPoint's gallery order: colour modes, then dark and light variations. +pub fn presets() -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = [ + ("none", "No Recolor"), + ("grayscale", "Grayscale"), + ("sepia", "Sepia"), + ("washout", "Washout"), + ("bw25", "Black and White: 25%"), + ("bw50", "Black and White: 50%"), + ("bw75", "Black and White: 75%"), + ] + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(); + for (id, label, _) in ACCENTS { + v.push((format!("{id}Dark"), format!("{label} Dark"))); + } + for (id, label, _) in ACCENTS { + v.push((format!("{id}Light"), format!("{label} Light"))); + } + v +} + +/// Set `a`'s recolour to `preset`, clearing any earlier one. `false` for an unknown preset. +pub fn apply(a: &mut PictureAdjust, preset: &str) -> bool { + let washed = a.brightness == 0.7 && a.contrast == -0.7; + let mut n = PictureAdjust { grayscale: false, duotone: None, bilevel: None, ..a.clone() }; + if washed { + n.brightness = 0.0; + n.contrast = 0.0; + } + let rgb = |r, g, b| ColorRef::rgb(Rgba::rgb(r, g, b)); + match preset { + "none" => {} + "grayscale" => n.grayscale = true, + // PowerPoint's sepia is a duotone from dark brown to light cream. + "sepia" => n.duotone = Some((rgb(0x40, 0x2A, 0x10), rgb(0xF2, 0xE3, 0xC6))), + // Washout is PowerPoint's +70% brightness, −70% contrast. + "washout" => { + n.brightness = 0.7; + n.contrast = -0.7; + } + "bw25" => n.bilevel = Some(0.25), + "bw50" => n.bilevel = Some(0.5), + "bw75" => n.bilevel = Some(0.75), + other => { + let Some((slot, dark)) = ACCENTS.iter().find_map(|(id, _, slot)| { + other.strip_prefix(id).and_then(|rest| match rest { + "Dark" => Some((*slot, true)), + "Light" => Some((*slot, false)), + _ => None, + }) + }) else { + return false; + }; + let c = ColorRef::scheme(slot); + n.duotone = Some(if dark { (rgb(0, 0, 0), c) } else { (c, rgb(0xFF, 0xFF, 0xFF)) }); + } + } + *a = n; + true +} + +fn recolor(s: &mut Session, p: &Value) -> Result { + let preset = str_param(p, "preset").ok_or_else(|| bad("picture.recolor", "missing `preset`"))?.to_string(); + if !presets().iter().any(|(id, _)| *id == preset) { + return Err(bad("picture.recolor", format!("unknown preset `{preset}`; see picture.recolors"))); + } + edit_shapes(s, p, "picture.recolor", |sh| { + if let ShapeKind::Picture { fill } = &mut sh.kind { + apply(&mut fill.adjust, &preset); + } + Ok(()) + }) +} + +fn transparent_color(s: &mut Session, p: &Value) -> Result { + let c = if bool_or(p, "none", false) { + None + } else { + let hex = str_param(p, "color").ok_or_else(|| bad("picture.transparentColor", "give `color` (#RRGGBB) or `none: true`"))?; + Some(Rgba::from_hex(hex.trim_start_matches('#')).ok_or_else(|| bad("picture.transparentColor", format!("bad colour `{hex}`")))?) + }; + edit_shapes(s, p, "picture.transparentColor", |sh| { + if let ShapeKind::Picture { fill } = &mut sh.kind { + fill.adjust.clear_color = c; + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs new file mode 100644 index 00000000..905f425d --- /dev/null +++ b/crates/engine/src/cmd/reuse.rs @@ -0,0 +1,266 @@ +//! Home ▸ New Slide ▸ Reuse Slides: insert slides from another presentation. +//! +//! The slides take the destination's look by default (each one moves to the destination layout +//! with the same name, else the same kind); with `keepFormatting` the source master and its +//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are +//! renumbered with animations, triggers and connector ends following them. + +use std::collections::HashMap; +use std::sync::Arc; + +use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session, media_refs}; + +pub fn specs() -> Vec { + vec![cmd!( + "slide.reuse", + "Reuse Slides…", + ["Home", "Slides", "New Slide"], + None, + "{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}", + has_doc, + reuse + )] +} + +/// Point every media id under `x` at its copy in the destination (`map`: source → destination). +fn remap(map: &HashMap) -> impl FnMut(&mut MediaId) + '_ { + move |id: &mut MediaId| { + if let Some(to) = map.get(id) { + *id = *to; + } + } +} + +fn shape_ids(shapes: &[deckcraft_model::Shape]) -> Vec { + let mut v = vec![]; + deckcraft_model::walk(shapes, &mut |sh, _| v.push(sh.id)); + v +} + +/// Point connector ends at the renumbered shapes. +fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap, depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if let ShapeKind::Connector { start, end } = &mut sh.kind { + for c in [start, end] { + if let Some((id, _)) = c.as_mut() { + *id = map.get(id).copied().unwrap_or(*id); + } + } + } + if let Some(ch) = sh.children_mut() { + remap_connectors(ch, map, depth + 1); + } + } +} + +/// Fresh shape ids for `shapes`, and the old → new map. +fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result> { + let old = shape_ids(shapes); + super::edit::reid("slide.reuse", doc, shapes)?; + let map: HashMap = old.into_iter().zip(shape_ids(shapes)).collect(); + remap_connectors(shapes, &map, 0); + Ok(map) +} + +/// The destination layout a reused slide moves to: same name, else same kind, else the first. +fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId { + let all = || doc.masters.iter().flat_map(|m| m.layouts.iter()); + all() + .find(|l| !name.is_empty() && l.name.eq_ignore_ascii_case(name)) + .or_else(|| all().find(|l| l.kind == kind)) + .or_else(|| all().next()) + .map(|l| l.id) + .unwrap_or_default() +} + +fn reuse(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?; + let src = super::file::open_presentation(&name, &bytes)?; + let n = src.slides.len(); + let picked: Vec = match p.get("slides") { + Some(Value::Array(a)) => { + let mut v = vec![]; + for x in a { + let i = x.as_u64().map(|i| i as usize).filter(|i| *i < n); + let i = i.ok_or_else(|| bad("slide.reuse", format!("{name} has {n} slides; {x} isn't one of them (indices count from 0)")))?; + if !v.contains(&i) { + v.push(i); + } + } + v + } + Some(Value::String(t)) if !t.trim().is_empty() => super::file::parse_slide_range(t, n)?, + _ => (0..n).collect(), + }; + if picked.is_empty() { + return Err(bad("slide.reuse", format!("{name} has no slides to reuse"))); + } + let keep = bool_or(p, "keepFormatting", false); + let at = usize_param(p, "at"); + s.edit(|doc, sel| { + let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); + // Media: what the slides use is copied under fresh ids. + let mut media_map: HashMap = HashMap::new(); + // Keep Source Formatting: bring each source master used, once, with fresh ids. + let mut layout_map: HashMap = HashMap::new(); + if keep { + let mut masters_done: Vec = vec![]; + for &i in &picked { + let Some((m, _)) = src.slides.get(i).and_then(|sl| src.master_for(sl)) else { continue }; + if masters_done.contains(&m.id) { + continue; + } + masters_done.push(m.id); + let mut used = vec![]; + media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id)); + copy_media(doc, &src, &used, &mut media_map)?; + let mut m = m.clone(); + media_refs::visit_mut::master(&mut m, &mut remap(&media_map)); + m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); + renumber(doc, &mut m.shapes)?; + for l in &mut m.layouts { + let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); + layout_map.insert(l.id, new); + l.id = new; + renumber(doc, &mut l.shapes)?; + } + doc.masters.push(Arc::new(m)); + } + } + let mut new_ids = vec![]; + for (k, &i) in picked.iter().enumerate() { + let Some(from) = src.slides.get(i) else { continue }; + copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?; + let mut sl: Slide = from.as_ref().clone(); + media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map)); + sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); + let map = renumber(doc, &mut sl.shapes)?; + for a in &mut sl.animations { + a.shape = map.get(&a.shape).copied().unwrap_or(a.shape); + a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t)); + } + sl.layout = match layout_map.get(&sl.layout) { + Some(l) => *l, + None => { + let (lname, kind) = + src.layout(from.layout).map(|(_, l)| (l.name.clone(), l.kind)).unwrap_or_else(|| (String::new(), Default::default())); + matching_layout(doc, &lname, kind) + } + }; + new_ids.push(sl.id); + doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl)); + } + sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1)); + sel.slides = new_ids.clone(); + sel.shapes.clear(); + sel.text = None; + Ok(json!({"slides": new_ids.len(), "ids": new_ids, "from": name, "keepFormatting": keep})) + }) +} + +/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in +/// `doc` are shared; a linked item keeps its link). +fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap) -> Result<()> { + for &id in used { + if map.contains_key(&id) { + continue; + } + let Some(m) = src.media(id) else { continue }; + let new = if m.link.is_some() { + let mut item = m.clone(); + item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); + let new = item.id; + doc.media.push(item); + new + } else { + doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))? + }; + map.insert(id, new); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use deckcraft_model::style::{Artistic, Fill, PictureFill}; + use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody}; + use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults}; + use serde_json::json; + + use super::*; + + /// Every kind of media a slide can hold comes along, each pointing at its own bytes: the + /// artistic-effect original, a background picture, poster, sounds and picture bullets too. + #[test] + fn reused_slides_bring_every_media_reference() { + let mut src = defaults::new_presentation(None); + let blobs: Vec> = (0..8u8).map(|i| vec![0xD0, i, i, i]).collect(); + let ids: Vec = blobs + .iter() + .enumerate() + .map(|(i, b)| src.add_media(&format!("m{i}.bin"), "application/octet-stream", b.clone()).expect("media")) + .collect(); + let id = |i: usize| ids.get(i).copied().unwrap_or_default(); + let mut text = TextBody::from_text("bullet"); + if let Some(p) = text.paragraphs.first_mut() { + p.props.bullet = Some(Bullet::Picture { media: id(6) }); + } + let slide = Arc::make_mut(src.slides.first_mut().expect("slide")); + slide.shapes = vec![ + Shape { + id: ShapeId(9001), + kind: ShapeKind::Picture { + fill: PictureFill { + media: id(0), + artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1) }), + ..Default::default() + }, + }, + ..Default::default() + }, + Shape { + id: ShapeId(9002), + kind: ShapeKind::Media(MediaClip { media: id(2), poster: Some(id(3)), ..Default::default() }), + ..Default::default() + }, + Shape { + id: ShapeId(9003), + click: Some(Hyperlink { + action: Action::NextSlide, + tooltip: String::new(), + highlight_click: false, + sound: Some(id(5)), + stop_sound: false, + zoom_return: None, + }), + text: Some(text), + ..Default::default() + }, + ]; + slide.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: id(4), ..Default::default() }) }); + slide.transition = Some(deckcraft_model::anim::Transition { sound: Some(id(7)), ..Default::default() }); + let bytes = deckcraft_format::save(&src).expect("save"); + + let mut s = Session::with_new(); + let own = vec![1u8, 2, 3]; + let own_id = s.edit(|doc, _| Ok(doc.add_media("own.bin", "application/octet-stream", own.clone()).expect("media"))).expect("own media"); + s.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0]})).expect("reuse"); + let doc = &s.doc().expect("doc").doc; + assert_eq!(doc.media(own_id).map(|m| m.data.as_slice() == own.as_slice()), Some(true), "own media untouched"); + let reused = doc.slides.get(1).expect("reused slide"); + let got: Vec> = + media_refs::slide_media(reused).iter().map(|m| doc.media(*m).map(|m| m.data.as_ref().clone()).unwrap_or_default()).collect(); + let mut got = got; + got.sort(); + let mut want = blobs.clone(); + want.sort(); + assert_eq!(got, want, "every reference resolves to its own copied bytes"); + } +} diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..65933bbd 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -6,7 +6,7 @@ use deckcraft_model::Comment; use serde_json::{Value, json}; use super::*; -use crate::{Result, Session, UiRequest}; +use crate::{Result, Session, Target, UiRequest}; pub fn specs() -> Vec { vec![ @@ -15,8 +15,51 @@ pub fn specs() -> Vec { cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete), cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), + cmd!( + noundo "comment.next", + "Next Comment", + ["Review", "Comments"], + None, + "{slide?, index?} → {slide, index, text}", + has_slide, + |s, p| neighbour(s, p, true) + ), + cmd!( + noundo "comment.prev", + "Previous Comment", + ["Review", "Comments"], + None, + "{slide?, index?} → {slide, index, text}", + has_slide, + |s, p| neighbour(s, p, false) + ), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), - cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), + cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape?, row?, col?, notes?, para, start, end, word, lang, suggestions}] (shapes, table cells and notes; repeated words as {repeated, deleteFrom}; runs marked noProof skipped; each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling), + cmd!( + "review.spellingChange", + "Change Spelling", + ["Review", "Proofing"], + None, + "{shape, word, to, all?, para?, start?, row?, col?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)", + has_doc, + spelling_change + ), + cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary), + cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info), + cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), + cmd!( + "review.replaceWord", + "Insert Synonym", + [], + None, + "{with}: replace the selected word (or the word at the caret) while editing text", + has_text, + insert_synonym + ), + cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare), + cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions), + cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept), + cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { let i = s.doc()?.selection.slide; @@ -27,7 +70,7 @@ pub fn specs() -> Vec { "Set Up Slide Show…", ["Slide Show", "Set Up"], None, - "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}", + "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?, custom?: name}", has_doc, setup ), @@ -43,24 +86,33 @@ pub fn specs() -> Vec { ] } -fn now() -> String { +/// Seconds since the Unix epoch: the system clock, or the browser's on wasm (which has no `SystemTime`). +fn now_secs() -> u64 { #[cfg(not(target_arch = "wasm32"))] { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let (y, m, d) = super::design::civil((secs / 86_400) as i64); - let t = secs % 86_400; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } #[cfg(target_arch = "wasm32")] { - String::new() + (js_sys::Date::now() / 1000.0).max(0.0) as u64 } } +/// A UTC timestamp as saved on comments: `2026-10-10T13:10:35Z`. +fn iso_utc(secs: u64) -> String { + let (y, m, d) = super::design::civil((secs / 86_400) as i64); + let t = secs % 86_400; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) +} + +fn now() -> String { + iso_utc(now_secs()) +} + fn add(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string(); let author = s.prefs.author.clone(); - let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); + let shape = id_param(p, "id", "comment.add")?.or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0)); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?); @@ -123,6 +175,51 @@ fn list(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default()) } +/// Flat `(slide, comment index)` list in deck order. +fn comment_threads(doc: &deckcraft_model::Presentation) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + for (si, sl) in doc.slides.iter().enumerate() { + for ci in 0..sl.comments.len() { + out.push((si, ci)); + } + } + out +} + +/// Step to the next or previous comment. With `{slide, index}` matching a thread, move one step +/// and stop at the ends. Without `index`, next is the first thread on or after `slide` (the +/// current slide when omitted) and previous is the last thread strictly before it. +fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result { + let cmd = if forward { "comment.next" } else { "comment.prev" }; + let (threads, slide, index) = { + let st = s.doc()?; + (comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index")) + }; + if threads.is_empty() { + return Err(bad(cmd, "no comments")); + } + let pos = index.and_then(|i| threads.iter().position(|&(sl, c)| sl == slide && c == i)); + let target = match (pos, forward) { + (Some(i), true) => threads.get(i + 1).copied(), + (Some(i), false) => i.checked_sub(1).and_then(|j| threads.get(j).copied()), + (None, true) => threads.iter().copied().find(|(sl, _)| *sl >= slide), + (None, false) => threads.iter().copied().rev().find(|(sl, _)| *sl < slide), + }; + let Some((slide, index)) = target else { + return Err(bad(cmd, "no more comments")); + }; + s.select(|doc, sel| { + sel.slide = slide; + sel.slides = doc.slides.get(slide).map(|x| vec![x.id]).unwrap_or_default(); + sel.shapes.clear(); + sel.text = None; + sel.cells = None; + sel.target = Target::Slides; + })?; + let text = s.doc()?.doc.slides.get(slide).and_then(|sl| sl.comments.get(index)).map(|c| c.text.clone()).unwrap_or_default(); + Ok(json!({"slide": slide, "index": index, "text": text})) +} + fn accessibility(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let mut out = vec![]; @@ -168,52 +265,271 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(out)) } -fn spelling(s: &mut Session, _p: &Value) -> Result { - // A small built-in list of frequent misspellings (full dictionaries come later). - const COMMON: &[&str] = &[ - "teh", - "recieve", - "seperate", - "occured", - "untill", - "wich", - "becuase", - "definately", - "thier", - "alot", - "accomodate", - "acheive", - "adress", - "begining", - "beleive", - "calender", - "commited", - "enviroment", - "goverment", - "independant", - "neccessary", - "occassion", - "publically", - "reccomend", - "tommorow", - "wierd", - ]; +/// Frequent misspellings, used when no dictionary for the language is installed. +const COMMON: &[&str] = &[ + "teh", + "recieve", + "seperate", + "occured", + "untill", + "wich", + "becuase", + "definately", + "thier", + "alot", + "accomodate", + "acheive", + "adress", + "begining", + "beleive", + "calender", + "commited", + "enviroment", + "goverment", + "independant", + "neccessary", + "occassion", + "publically", + "reccomend", + "tommorow", + "wierd", +]; + +/// The language a check uses: the `lang` parameter, else the first run that says, else US English. +fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String { + if let Some(l) = str_param(p, "lang") { + return l.to_string(); + } + let mut found = None; + for sl in &doc.slides { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if found.is_none() + && let Some(t) = &sh.text + { + found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()); + } + }); + } + found.unwrap_or_else(|| "en-US".into()) +} + +fn spelling(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + let default = check_lang(&st.doc, p); + let ignore: Vec = + p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default(); + let mut dicts: std::collections::HashMap>> = Default::default(); let mut out = vec![]; + // Each misspelt word of `para`, reported with `at` (where the paragraph is) merged in. + let mut check = |para: &deckcraft_model::Paragraph, at: Value, out: &mut Vec| { + for w in crate::spell::repeated(para, &default) { + let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "repeated": true, "deleteFrom": w.repeated, "suggestions": []}); + if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) { + o.extend(a.clone()); + } + out.push(v); + } + let rep: Vec = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect(); + for w in crate::spell::paragraph_words(para, &default) { + if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) { + continue; + } + let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); + let bad = match &dict { + Some(d) => !crate::spell::is_correct(d, &w.text), + None => COMMON.contains(&w.text.to_lowercase().as_str()), + }; + if bad { + let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default(); + let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug}); + if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) { + o.extend(a.clone()); + } + out.push(v); + } + } + }; for (i, sl) in st.doc.slides.iter().enumerate() { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { if let Some(t) = &sh.text { - for w in t.text().split(|c: char| !c.is_alphanumeric() && c != '\'') { - if COMMON.contains(&w.to_lowercase().as_str()) { - out.push(json!({"slide": i, "shape": sh.id, "word": w})); + for (pi, para) in t.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "shape": sh.id, "para": pi}), &mut out); + } + } + if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { + for (r, row) in tb.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate().filter(|(_, c)| !c.is_covered()) { + for (pi, para) in cell.text.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "shape": sh.id, "row": r, "col": c, "para": pi}), &mut out); + } } } } }); + // Speaker notes, a paragraph per line. + for (pi, para) in sl.notes.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "notes": true, "para": pi}), &mut out); + } } Ok(Value::Array(out)) } +fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?; + crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?; + Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())})) +} + +/// Replace whole-word `from` with `to` in `text`; how many were replaced. +fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize { + let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; + let mut n = 0; + let mut at = 0; + while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { + let end = i + from.len(); + let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); + let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); + if before || after { + at = end; + continue; + } + text.replace_range(i..end, to); + n += 1; + at = i + to.len(); + if !all { + break; + } + } + n +} + +fn spelling_change(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string(); + let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string(); + let all = bool_or(p, "all", false); + let shape = p.get("shape").and_then(Value::as_u64); + if shape.is_none() && !all { + return Err(bad("review.spellingChange", "give `shape`, or `all: true`")); + } + // One exact occurrence (a squiggled word): paragraph `para`, from character `start`. + if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) { + let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?); + let end = start + word.chars().count(); + let cell = usize_param(p, "row").zip(usize_param(p, "col")); + let slide = { + let st = s.doc()?; + let found = st.doc.slides.iter().position(|sl| { + deckcraft_model::find_shape(&sl.shapes, id) + .and_then(|sh| match (cell, &sh.kind) { + (Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell(r, c)?.text.paragraphs.get(para), + (Some(_), _) => None, + (None, _) => sh.text.as_ref()?.paragraphs.get(para), + }) + .is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::() == word) + }); + found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))? + }; + s.edit(|doc, _| { + let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?; + let pp = match (cell, &mut sh.kind) { + (Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell_mut(r, c).and_then(|cl| cl.text.paragraphs.get_mut(para)), + (Some(_), _) => None, + (None, _) => sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)), + }; + if let Some(pp) = pp { + replace_chars(pp, start, end, &to); + } + Ok(()) + })?; + return Ok(json!({"changed": 1})); + } + // Nothing to change makes no undo step. + let mut any = false; + let st = s.doc()?; + for sl in &st.doc.slides { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id)) + && let Some(t) = &sh.text + { + any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0); + } + }); + } + if !any { + return Ok(json!({"changed": 0})); + } + let changed = s.edit(|doc, _| { + let mut n = 0; + let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()); + for sl in &mut doc.slides { + let mut hit = false; + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str())); + }); + if !hit || (!all && n > 0) { + continue; + } + walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| { + if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) { + return; + } + let Some(t) = sh.text.as_mut() else { return }; + for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) { + if !all && n > 0 { + break; + } + n += replace_word(&mut r.text, &word, &to, all); + } + }); + } + Ok(n) + })?; + Ok(json!({"changed": changed})) +} + +/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose +/// the rest of the range). +fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) { + let mut at = 0; + let mut placed = false; + for r in &mut p.runs { + let n = r.char_len(); + let (a, b) = (start.max(at), end.min(at + n)); + if a < b && r.kind == deckcraft_model::text::RunKind::Text { + let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i); + let (x, y) = (byte(a - at), byte(b - at)); + r.text.replace_range(x..y, if placed { "" } else { to }); + placed = true; + } + at += n; + } + p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty())); +} + +fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { + if depth > 64 { + return; + } + for sh in shapes.iter_mut() { + f(sh); + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + walk_text_mut(children, depth + 1, f); + } + } +} + +fn dictionary_info(s: &mut Session, p: &Value) -> Result { + let lang = match s.doc() { + Ok(st) => check_lang(&st.doc, p), + Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(), + }; + let dirs = crate::spell::search_dirs(); + let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string()); + let available = crate::spell::dictionary(&lang).is_some(); + Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::>()})) +} + fn start(s: &mut Session, from: usize) -> Result { s.ui_requests.push(UiRequest::StartShow { from }); Ok(json!({"from": from})) @@ -240,11 +556,25 @@ fn setup(s: &mut Session, p: &Value) -> Result { if let Some(v) = bool_param(p, "useTimings") { sh.use_timings = v; } + if let Some(v) = bool_param(p, "showMediaControls") { + sh.show_media_controls = v; + } match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) { (Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)), (None, None) if p.get("all").is_some() => sh.range = None, _ => {} } + if p.get("all").and_then(Value::as_bool) == Some(true) { + sh.range = None; + sh.custom_show = None; + } + if p.get("custom").is_some() { + let name = p.get("custom").and_then(Value::as_str).unwrap_or(""); + sh.custom_show = if name.is_empty() { None } else { Some(name.to_string()) }; + if sh.custom_show.is_some() { + sh.range = None; + } + } Ok(serde_json::to_value(&*sh).unwrap_or_default()) }) } @@ -264,3 +594,203 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +/// The selected text when it lies in one paragraph, else the word at the caret. +fn word_under_caret(s: &Session) -> Option { + let st = s.doc().ok()?; + let t = st.selection.text.clone()?; + let body = super::text::body_of(st, &t)?; + let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) }; + if a.0 != b.0 { + return None; + } + let text = body.paragraphs.get(a.0)?.text(); + let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect(); + let w = w.trim().to_string(); + (!w.is_empty()).then_some(w) +} + +fn thesaurus(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default(); + let senses = crate::thesaurus::lookup(&word); + Ok(json!({"word": word, "senses": senses})) +} + +fn insert_synonym(s: &mut Session, p: &Value) -> Result { + let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string(); + if with.chars().count() > 200 { + return Err(bad("review.replaceWord", "too long")); + } + let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret); + if collapsed { + s.execute("text.selectWord", &json!({}))?; + } + s.execute("text.insert", &json!({"text": with})) +} + +fn comparing(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } +} + +fn revised_bytes(p: &Value) -> Result<(String, Vec)> { + if let Some(data) = str_param(p, "data") { + let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?; + return Ok((path.to_string(), bytes)); + } + Err(bad("review.compare", "give `path` or `data`")) +} + +fn revisions_json(st: &crate::DocState) -> Result { + let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?; + let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone()); + Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position})) +} + +fn compare(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = revised_bytes(p)?; + let revised = super::file::open_presentation(&name, &bytes)?; + let st = s.doc_mut()?; + let by_position = crate::compare::unrelated(&st.doc, &revised); + st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position }); + s.ui_requests.push(UiRequest::Pane { id: "revisions".into() }); + revisions_json(s.doc()?) +} + +fn revisions(s: &mut Session, _: &Value) -> Result { + revisions_json(s.doc()?) +} + +fn accept(s: &mut Session, p: &Value) -> Result { + let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?; + let all = bool_param(p, "all").unwrap_or(false); + let index = usize_param(p, "index"); + if !all && index.is_none() { + return Err(bad("review.accept", "give `index` or `all`")); + } + s.edit(|doc, sel| { + let mut accepted = 0usize; + // Each accept shifts indices, so the list is recomputed; bounded by the slide counts. + let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1); + for _ in 0..limit { + let changes = crate::compare::diff(doc, &rev, by_pos); + let c = if all { + changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1)) + } else { + changes.into_iter().nth(index.unwrap_or(0)) + }; + let Some(c) = c else { break }; + let Some(at) = crate::compare::accept(doc, &rev, &c)? else { break }; + accepted += 1; + sel.slide = at; + sel.shapes.clear(); + sel.text = None; + if !all { + break; + } + } + if accepted == 0 { + return Err(bad("review.accept", "no such revision")); + } + Ok(json!({"accepted": accepted})) + }) +} + +fn end_compare(s: &mut Session, _: &Value) -> Result { + s.doc_mut()?.compare = None; + ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + use crate::Session; + + #[test] + fn previous_and_next_comment_walk_the_deck() { + let mut s = Session::with_new(); + let err = s.execute("comment.next", &json!({})).unwrap_err(); + assert!(err.to_string().contains("no comments"), "{err}"); + + s.execute("comment.add", &json!({"text": "on first"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("comment.add", &json!({"text": "on second a"})).unwrap(); + s.execute("comment.add", &json!({"text": "on second b"})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.slide, 2); + + let err = s.execute("comment.next", &json!({})).unwrap_err(); + assert!(err.to_string().contains("no more comments"), "{err}"); + + let undo_before = s.doc().unwrap().history.undo.len(); + let back = s.execute("comment.prev", &json!({})).unwrap(); + assert_eq!(back["slide"], 1); + assert_eq!(back["index"], 1); + assert_eq!(back["text"], "on second b"); + assert_eq!(s.doc().unwrap().selection.slide, 1); + assert!(s.doc().unwrap().selection.shapes.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + + let prev = s.execute("comment.prev", &json!({"slide": 1, "index": 1})).unwrap(); + assert_eq!(prev["index"], 0); + assert_eq!(prev["text"], "on second a"); + let first = s.execute("comment.prev", &json!({"slide": prev["slide"], "index": prev["index"]})).unwrap(); + assert_eq!(first["slide"], 0); + assert_eq!(first["text"], "on first"); + let err = s.execute("comment.prev", &json!({"slide": 0, "index": 0})).unwrap_err(); + assert!(err.to_string().contains("no more comments"), "{err}"); + + let next = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap(); + assert_eq!(next["slide"], 1); + assert_eq!(next["text"], "on second a"); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let here = s.execute("comment.next", &json!({})).unwrap(); + assert_eq!(here["text"], "on first"); + } + + #[test] + fn timestamps_are_iso_utc() { + assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_utc(1_791_637_835), "2026-10-10T13:10:35Z"); + } + + #[test] + fn the_clock_is_not_empty() { + // On wasm this used to return an empty string, so browser comments were saved undated. + assert!(now().starts_with("20"), "{}", now()); + } + + #[test] + fn show_media_controls_follow_setup() { + let mut s = Session::with_new(); + assert!(s.doc().expect("doc").doc.show.show_media_controls); + let off = s.execute("show.setup", &json!({"showMediaControls": false})).expect("off"); + assert_eq!(off["showMediaControls"], false); + assert_eq!(off["useTimings"], true, "other setup flags stay"); + let timings = s.execute("show.setup", &json!({"useTimings": false})).expect("timings"); + assert_eq!(timings["showMediaControls"], false); + assert_eq!(timings["useTimings"], false); + s.execute("show.setup", &json!({"showMediaControls": true})).expect("on"); + assert!(s.doc().expect("doc").doc.show.show_media_controls); + } + + #[test] + fn setup_remembers_a_custom_show() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("show.customShow", &json!({"name": "Short", "slides": [0, 1]})).unwrap(); + let v = s.execute("show.setup", &json!({"custom": "Short"})).unwrap(); + assert_eq!(v.get("customShow").and_then(Value::as_str), Some("Short")); + assert!(s.active().unwrap().doc.show.range.is_none()); + s.execute("show.setup", &json!({"custom": ""})).unwrap(); + assert!(s.active().unwrap().doc.show.custom_show.is_none()); + } +} diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs new file mode 100644 index 00000000..69f70686 --- /dev/null +++ b/crates/engine/src/cmd/richclip.rs @@ -0,0 +1,346 @@ +//! Rich text on the system clipboard, as HTML. +//! +//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes +//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive. +//! Anything else in the markup is skipped. A hostile fragment is capped, never executed. + +use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody}; + +const MAX_HTML: usize = 1 << 20; +const MAX_PARAS: usize = 2_000; +const MAX_RUNS: usize = 10_000; + +/// HTML for `body`: one `

` per paragraph, ``, `` and `` for the formatting. +pub fn body_to_html(body: &TextBody) -> String { + let mut out = String::new(); + for (i, p) in body.paragraphs.iter().enumerate() { + if i > 0 { + out.push_str("
"); + } + out.push_str("

"); + for r in &p.runs { + let (open, close) = tags(&r.props); + out.push_str(&open); + match &r.kind { + deckcraft_model::text::RunKind::Break => out.push_str("
"), + _ => push_escaped(&mut out, &r.text), + } + out.push_str(&close); + if out.len() > MAX_HTML { + break; + } + } + out.push_str("

"); + } + out +} + +fn tags(props: &RunProps) -> (String, String) { + let mut open = String::new(); + let mut close = String::new(); + if props.bold == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.italic == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) { + open.push_str(""); + close.insert_str(0, ""); + } + (open, close) +} + +fn push_escaped(out: &mut String, text: &str) { + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } +} + +/// The formatted text of an HTML fragment, or `None` when it has no text. +/// +/// Understands ``, ``, ``, ``, ``, `
`, paragraphs, and a `style` that +/// says bold, italic or underline. Other tags are ignored. The result is capped. +pub fn html_to_body(html: &str) -> Option { + if html.len() > MAX_HTML { + return None; + } + let bytes = html.as_bytes(); + let mut i = 0; + let mut bold = 0i32; + let mut italic = 0i32; + let mut underline = 0i32; + let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new(); + let mut paragraphs: Vec = vec![Paragraph::default()]; + let mut runs = 0usize; + let mut skip = 0i32; + while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS { + if bytes[i] == b'<' { + let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len()); + let raw = &html[i + 1..end]; + i = (end + 1).min(bytes.len()); + let token = raw.trim(); + if token.starts_with('!') || token.starts_with('?') { + continue; + } + let closing = token.starts_with('/'); + let name_src = token.trim_start_matches('/').trim(); + let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len()); + let name = name_src[..name_end].to_ascii_lowercase(); + if name == "script" || name == "style" || name == "head" { + if closing { + skip = (skip - 1).max(0); + } else if !token.ends_with('/') { + skip += 1; + } + continue; + } + if skip > 0 { + continue; + } + if closing { + let kind = tag_kind(&name); + if !kind.is_empty() + && let 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 is_block(&name) { + new_para(&mut paragraphs); + } + continue; + } + if is_break(&name) { + new_para(&mut paragraphs); + continue; + } + let (db, di, du) = tag_style(&name, name_src); + if db + di + du > 0 { + bold += db; + italic += di; + underline += du; + if !token.ends_with('/') { + stack.push((tag_kind(&name), db, di, du)); + } else { + bold -= db; + italic -= di; + underline -= du; + } + } + continue; + } + if skip > 0 { + i += 1; + continue; + } + let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len()); + let text = decode_text(&html[i..end]); + i = end; + if text.is_empty() { + continue; + } + push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0); + } + while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 { + paragraphs.pop(); + } + if paragraphs.iter().all(Paragraph::is_empty) { + return None; + } + for p in &mut paragraphs { + p.normalize(); + } + Some(TextBody { paragraphs, ..Default::default() }) +} + +fn tag_kind(name: &str) -> &'static str { + match name { + "b" | "strong" => "b", + "i" | "em" => "i", + "u" => "u", + "span" | "font" => "span", + "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block", + _ => "", + } +} + +fn is_break(name: &str) -> bool { + name == "br" || name == "hr" +} + +fn is_block(name: &str) -> bool { + matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote") +} + +fn tag_style(name: &str, src: &str) -> (i32, i32, i32) { + let mut bold = i32::from(name == "b" || name == "strong"); + let mut italic = i32::from(name == "i" || name == "em"); + let mut underline = i32::from(name == "u"); + if let Some(style) = attr(src, "style") { + let s = style.to_ascii_lowercase(); + if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) { + bold = 1; + } + if s.contains("font-style:italic") || s.contains("font-style: italic") { + italic = 1; + } + if s.contains("text-decoration:underline") + || s.contains("text-decoration: underline") + || s.contains("text-decoration-line:underline") + || s.contains("text-decoration-line: underline") + { + underline = 1; + } + } + (bold, italic, underline) +} + +fn weight_bold(style: &str) -> bool { + let Some(at) = style.find("font-weight") else { return false }; + let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9') +} + +fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> { + let lower = tag.to_ascii_lowercase(); + let at = lower.find(key)?; + let after = tag.get(at + key.len()..)?.trim_start(); + let after = after.strip_prefix('=')?.trim_start(); + let quote = after.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = after.get(quote.len_utf8()..)?; + let end = rest.find(quote)?; + rest.get(..end) +} + +fn new_para(paragraphs: &mut Vec) { + if paragraphs.len() >= MAX_PARAS { + return; + } + if paragraphs.last().is_some_and(|p| !p.is_empty()) { + paragraphs.push(Paragraph::default()); + } +} + +fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) { + if *runs >= MAX_RUNS { + return; + } + let Some(p) = paragraphs.last_mut() else { return }; + let mut props = RunProps::default(); + if bold { + props.bold = Some(true); + } + if italic { + props.italic = Some(true); + } + if underline { + props.underline = Some("sng".into()); + } + p.runs.push(Run::with(text, props)); + *runs += 1; +} + +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'], "")); + rest = &rest[at..]; + let Some(end) = rest.find(';') else { + out.push_str(&rest.replace(['\n', '\r'], "")); + break; + }; + let entity = &rest[1..end]; + rest = &rest[end + 1..]; + match entity { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + "nbsp" => out.push(' '), + _ => { + let ch = entity.strip_prefix('#').and_then(numeric_char); + if let Some(c) = ch { + out.push(c); + } + } + } + } + if !rest.is_empty() && !rest.starts_with('&') { + out.push_str(&rest.replace(['\n', '\r'], "")); + } + out +} + +fn numeric_char(digits: &str) -> Option { + let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X')); + let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? }; + char::from_u32(n).filter(|c| !c.is_control() || *c == '\n') +} + +/// Do the HTML's words and the paste's plain text say the same thing? +pub fn same_words(html_plain: &str, pasted: &str) -> bool { + let a = fold(html_plain); + let b = fold(pasted); + a == b +} + +fn fold(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bold_italic_and_underline_round_trip() { + let mut body = TextBody::from_text("Hi there"); + body.paragraphs[0].runs = vec![ + Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }), + Run::new(" "), + Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }), + ]; + let html = body_to_html(&body); + assert!(html.contains("Hi"), "{html}"); + assert!(html.contains("there") || html.contains("there"), "{html}"); + let back = html_to_body(&html).unwrap(); + assert_eq!(back.text(), "Hi there"); + assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true)); + let last = back.paragraphs[0].runs.last().unwrap(); + assert_eq!(last.props.italic, Some(true)); + assert!(last.props.underline.is_some()); + } + + #[test] + fn pages_style_and_escapes() { + let html = "

A & B

C

"; + let body = html_to_body(html).unwrap(); + assert_eq!(body.text(), "A & B\nC"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true)); + assert!(same_words(&body.text(), "A & B\nC")); + assert!(!same_words(&body.text(), "other")); + } + + #[test] + fn a_huge_fragment_is_refused() { + let html = "x".repeat(MAX_HTML + 1); + assert!(html_to_body(&html).is_none()); + } +} diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs new file mode 100644 index 00000000..e920ce93 --- /dev/null +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -0,0 +1,64 @@ +//! The ruler's data and tab-stop editing. + +use serde_json::json; + +use crate::Session; + +fn editing() -> (Session, u64) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "a\tb"})).unwrap(); + (s, id) +} + +#[test] +fn ruler_reports_the_text_area_and_indents() { + let (mut s, _) = editing(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}"); + assert_eq!(r["rotated"], false); + assert_eq!(r["tabs"].as_array().unwrap().len(), 0); + s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0))); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text"); +} + +#[test] +fn tabs_add_move_remove_clear_and_undo() { + let (mut s, _) = editing(); + let tabs = |s: &mut Session| -> Vec<(f64, String)> { + s.execute("format.ruler", &json!({})).unwrap()["tabs"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string())) + .collect() + }; + s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap(); + s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted"); + s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced"); + s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap(); + s.execute("format.tabs", &json!({"remove": 72.2})).unwrap(); + assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tabs(&mut s).len(), 2); + s.execute("format.tabs", &json!({"clear": true})).unwrap(); + assert!(tabs(&mut s).is_empty()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); + assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); + for i in 0..100 { + s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); + } + assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); +} diff --git a/crates/engine/src/cmd/selection.rs b/crates/engine/src/cmd/selection.rs index 20890418..76eaa706 100644 --- a/crates/engine/src/cmd/selection.rs +++ b/crates/engine/src/cmd/selection.rs @@ -28,7 +28,10 @@ pub fn specs() -> Vec { /// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden /// object from the list shows it again. fn select(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let ids = match ids_param(p, "ids") { + Some(ids) => ids, + None => id_param(p, "id", "selection.select")?.map(|i| vec![i]).unwrap_or_default(), + }; let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false)); let st = s.doc()?; let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some()); diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs index 11d99b7c..69bcc3c8 100644 --- a/crates/engine/src/cmd/selection_tests.rs +++ b/crates/engine/src/cmd/selection_tests.rs @@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); s.execute("edit.select", &json!({"ids": []})).unwrap(); let n = s.doc().unwrap().history.undo.len(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // A: x 20..120, y 50..130. s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); assert_eq!(selected(&s), vec![a], "a click selects a locked object"); diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..5b0dc5be 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -2,7 +2,7 @@ //! names and alt text. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; -use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow}; use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json}; @@ -29,20 +29,63 @@ pub fn specs() -> Vec { has_slide, freeform ), + cmd!( + "draw.inkToShape", + "Ink to Shape", + ["Draw", "Convert"], + None, + "{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles", + has_slide, + ink_to_shape + ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), - cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), - cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!( + "shape.move", + "Move", + [], + None, + "{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}", + has_selection, + move_by + ), + cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), - cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), + cmd!( + "shape.rotate", + "Rotation", + ["Shape Format", "Arrange", "Rotate"], + None, + "{deg?: absolute, by?: relative, ids?, gesture?: move|end}", + has_selection, + rotate + ), + cmd!( + "shape.sizePosition", + "Size and Position…", + ["Shape Format", "Size"], + None, + "{x?, y?: pt, w?, h?: pt, rot?: deg, scaleW?, scaleH?: percent of the current size (100 = unchanged), lockAspect?: bool, ids?} — with none of those keys, opens the dialog", + has_selection, + size_position + ), cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust), cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change), + cmd!( + "shape.smoothCorners", + "Smooth Corners", + ["Shape Format", "Insert Shapes", "Edit Shape"], + None, + "{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners", + has_selection, + smooth_corners + ), cmd!( "shape.fill", "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}", + "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill", has_selection, fill ), @@ -51,7 +94,7 @@ pub fn specs() -> Vec { "Shape Outline", ["Shape Format", "Shape Styles"], None, - "{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?, join?, head?: none|triangle|stealth|diamond|oval|arrow, tail?, reset?, ids?}", + "{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}", has_selection, line ), @@ -60,7 +103,7 @@ pub fn specs() -> Vec { "Shape Effects", ["Shape Format", "Shape Styles"], None, - "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, reset?, ids?}", + "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)", has_selection, effects ), @@ -86,7 +129,15 @@ pub fn specs() -> Vec { lock ), cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), - cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), + cmd!( + "picture.crop", + "Crop", + ["Picture Format", "Size"], + None, + "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}", + has_selection, + crop + ), cmd!( "picture.adjust", "Corrections", @@ -173,7 +224,169 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { }) } +const SIZE_POS: &str = "shape.sizePosition"; +/// Smallest and largest side the dialog will write, in points (220 in). +const MIN_PT: f64 = 0.01; +const MAX_PT: f64 = 15_840.0; +const POS_LIMIT: f64 = 100_000.0; +const MAX_SCALE: f64 = 10_000.0; + +#[derive(Clone, Copy)] +struct SizeSpec { + x: Option, + y: Option, + w: Option, + h: Option, + rot: Option, + scale_w: Option, + scale_h: Option, + lock: bool, +} + +impl SizeSpec { + fn width_set(self) -> bool { + self.w.is_some() || self.scale_w.is_some() + } + fn height_set(self) -> bool { + self.h.is_some() || self.scale_h.is_some() + } + fn any(self) -> bool { + self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some() + } +} + +fn finite_key(p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(n) if n.is_finite() => Ok(Some(n)), + _ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))), + }, + } +} + +fn in_range(v: Option, key: &str, lo: f64, hi: f64) -> Result> { + if let Some(n) = v + && !(lo..=hi).contains(&n) + { + return Err(bad(SIZE_POS, format!("`{key}` is out of range"))); + } + Ok(v) +} + +fn size_spec(p: &Value) -> Result { + let lock = match p.get("lockAspect") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")), + }; + Ok(SizeSpec { + x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?, + y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?, + w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?, + h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?, + rot: finite_key(p, "rot")?, + scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?, + scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?, + lock, + }) +} + +fn norm_deg(r: f64) -> f64 { + let r = r % 360.0; + if r < 0.0 { r + 360.0 } else { r } +} + +/// Width wins when both sides are set with the aspect locked, matching `shape.resize`. +fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) { + let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; + let mut nw = x.w; + let mut nh = x.h; + if let Some(p) = spec.scale_w { + nw = x.w * p / 100.0; + } + if let Some(p) = spec.scale_h { + nh = x.h * p / 100.0; + } + if let Some(w) = spec.w { + nw = w; + } + if let Some(h) = spec.h { + nh = h; + } + if spec.lock && (spec.width_set() || spec.height_set()) { + if spec.width_set() { + nh = if ar > 0.0 { nw / ar } else { nh }; + } else { + nw = nh * ar; + } + } + x.w = nw.clamp(MIN_PT, MAX_PT); + x.h = nh.clamp(MIN_PT, MAX_PT); + if let Some(px) = spec.x { + x.x = px; + } + if let Some(py) = spec.y { + x.y = py; + } + if let Some(r) = spec.rot { + x.rot = norm_deg(r); + } +} + +fn size_position(s: &mut Session, p: &Value) -> Result { + let spec = size_spec(p)?; + if !spec.any() { + let (x, y, w, h, rot) = { + let st = s.doc()?; + let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?; + let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?; + let x = xfrm_of(&st.doc, &st.selection, sh); + (x.x, x.y, x.w, x.h, x.rot) + }; + s.ui_requests.push(crate::UiRequest::Dialog { + id: "sizePosition".into(), + params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}), + }); + return Ok(Value::Null); + } + let changed = { + let ids = targets(s, p)?; + let st = s.doc()?; + let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::>(); + if !ids.is_empty() && free.is_empty() { + return Err(bad(SIZE_POS, "the object is locked")); + } + free.iter().any(|id| { + st.shape(*id).is_some_and(|sh| { + let mut x = xfrm_of(&st.doc, &st.selection, sh); + let before = x; + apply_size(&mut x, spec); + x != before + }) + }) + }; + if !changed { + return Ok(Value::Null); + } + edit_geometry(s, p, SIZE_POS, |sh| { + if let Some(x) = sh.xfrm.as_mut() { + apply_size(x, spec); + } + Ok(()) + }) +} + fn rotate(s: &mut Session, p: &Value) -> Result { + // A trackpad twist sends many small steps. `gesture: move` keeps them as one undo; + // `gesture: end` commits that step and does not rotate again. + if p.get("gesture").and_then(Value::as_str) == Some("end") { + s.commit_named("Rotation"); + return Ok(json!({})); + } + if p.get("gesture").and_then(Value::as_str) == Some("move") { + s.begin("Rotation"); + } let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); edit_geometry(s, p, "shape.rotate", |sh| { if let Some(x) = sh.xfrm.as_mut() { @@ -216,6 +429,47 @@ fn adjust(s: &mut Session, p: &Value) -> Result { }) } +/// 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. +fn smooth_corners(s: &mut Session, p: &Value) -> Result { + 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(()) + }) +} + fn change(s: &mut Session, p: &Value) -> Result { 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}`")))?; @@ -284,14 +538,15 @@ fn fill(s: &mut Session, p: &Value) -> Result { } else if p.get("picture").is_some() { let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?; let ct = super::insert::content_type(&name, &bytes); - let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("shape.fill", NO_ID_SPACE)))?; Some(Some(Fill::Picture(PictureFill { media, ..Default::default() }))) } else { None }; let transparency = f64_param(p, "transparency"); - if new_fill.is_none() && transparency.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + 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 st = s.doc()?; @@ -319,6 +574,14 @@ fn fill(s: &mut Session, p: &Value) -> Result { if let Some(f) = &new_fill { sh.fill.clone_from(f); } + if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) { + picture_props(pf, pp); + } + if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); + return Ok(()); + } if let Some(t) = transparency { let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone())); if let Some(Fill::Solid { mut color }) = base { @@ -333,6 +596,54 @@ fn fill(s: &mut Session, p: &Value) -> Result { }) } +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +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 { if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } @@ -350,6 +661,15 @@ fn line(s: &mut Session, p: &Value) -> Result { "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, @@ -376,11 +696,20 @@ fn line(s: &mut Session, p: &Value) -> Result { if dash.is_some() { l.dash.clone_from(&dash); } + let keep_size = |old: &Option, new: &Option| { + 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.clone_from(h); + l.head = keep_size(&l.head, h); } if let Some(t) = &tail { - l.tail.clone_from(t); + l.tail = keep_size(&l.tail, t); } if cap.is_some() { l.cap = cap; @@ -388,6 +717,16 @@ fn line(s: &mut Session, p: &Value) -> Result { 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(()) }) } @@ -449,7 +788,11 @@ fn effects(s: &mut Session, p: &Value) -> Result { "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; @@ -481,13 +824,89 @@ fn effects(s: &mut Session, p: &Value) -> Result { if let Some(r) = &refl { e.reflection.clone_from(r); } - if let Some(b) = &bevel { - e.bevel.clone_from(b); + if bevel.is_some() || three_d.is_some() { + let mut s3 = e.raw3d.as_deref().map(super::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 = super::three_d::Shape3d::default(); + } + s3.apply(t); + } + super::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 { + 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, Option) { let slots = [ @@ -664,6 +1083,9 @@ fn picture_reset(s: &mut Session, p: &Value) -> Result { fill.adjust = Default::default(); fill.crop = [0.0; 4]; fill.alpha = None; + if let Some(a) = fill.artistic.take() { + fill.media = a.original; + } } sh.effects = None; sh.line = None; @@ -674,11 +1096,12 @@ fn picture_reset(s: &mut Session, p: &Value) -> Result { fn picture_change(s: &mut Session, p: &Value) -> Result { let (name, bytes) = super::insert::media_bytes(p, "picture.change")?; let ct = super::insert::content_type(&name, &bytes); - let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?; edit_shapes(s, p, "picture.change", |sh| { if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { fill.media = media; fill.crop = [0.0; 4]; + fill.artistic = None; } Ok(()) }) @@ -860,3 +1283,250 @@ fn freeform(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"id": id.0})) } + +#[cfg(test)] +mod size_position_tests { + use serde_json::json; + + use crate::Session; + + fn deck() -> (Session, u32) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32; + (s, id) + } + + fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) { + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + let b = &v["box"]; + (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0)) + } + + fn undos(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + #[test] + fn sets_position_size_and_rotation_in_one_undo() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap(); + assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0)); + assert_eq!(undos(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + } + + #[test] + fn lock_aspect_and_scale_share_one_step() { + let (mut s, id) = deck(); + s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); + // An explicit width wins over a scale on the same axis. + s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap(); + assert_eq!(geom(&mut s, id).2, 80.0); + } + + #[test] + fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0)); + assert_eq!(undos(&s), n + 1); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap(); + assert_eq!(undos(&s), n, "identical geometry is not an undo step"); + } + + #[test] + fn no_fields_opens_the_dialog_without_editing() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + assert_eq!(undos(&s), n); + let req = s.ui_requests.last().unwrap(); + match req { + crate::UiRequest::Dialog { id, params } => { + assert_eq!(id, "sizePosition"); + assert_eq!(params["w"], 200.0); + assert_eq!(params["h"], 100.0); + assert_eq!(params["x"], 40.0); + assert_eq!(params["rot"], 0.0); + assert_eq!(params["scaleW"], 100.0); + assert_eq!(params["lockAspect"], false); + } + other => panic!("expected a dialog, got {other:?}"), + } + } + + #[test] + fn bad_fields_do_not_edit() { + let (mut s, id) = deck(); + let n = undos(&s); + for p in [ + json!({"w": "wide"}), + json!({"h": -5.0}), + json!({"scaleW": 0.0}), + json!({"x": 1e9}), + json!({"rot": "spin"}), + json!({"lockAspect": "yes"}), + ] { + let err = s.execute("shape.sizePosition", &p).unwrap_err(); + assert!(err.to_string().contains("sizePosition"), "{err}"); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + assert_eq!(undos(&s), n); + } + } + + #[test] + fn scale_is_per_shape_and_nothing_selected_is_disabled() { + let (mut s, a) = deck(); + let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap(); + assert_eq!(geom(&mut s, a).2, 400.0); + assert_eq!(geom(&mut s, a).3, 100.0); + assert_eq!(geom(&mut s, b).2, 80.0); + assert_eq!(geom(&mut s, b).3, 80.0); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let err = s.execute("shape.sizePosition", &json!({})).unwrap_err(); + assert!(err.to_string().contains("nothing selected"), "{err}"); + } +} + +/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape +/// outlined in the stroke's colour and width; the rest stays ink. One undo step. +fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { + use crate::inkshape::{Recognized, recognize}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let shapes = s.doc()?.shapes(); + let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { + return Ok(json!({"shapes": [], "kept": 0})); + }; + let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); + let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; + let found: Vec> = strokes + .iter() + .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) + .collect(); + if found.iter().all(Option::is_none) { + return Ok(json!({"shapes": [], "kept": strokes.len()})); + } + s.edit(|doc, sel| { + let mut new = vec![]; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; + let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; + let all = std::mem::take(strokes); + let mut kept = vec![]; + let mut made = vec![]; + for (st, r) in all.into_iter().zip(found.iter()) { + let Some(r) = r else { + kept.push(st); + continue; + }; + let (preset, xfrm) = match *r { + Recognized::Line { x0, y0, x1, y1 } => { + ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) + } + Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), + }; + made.push((preset, xfrm, st.color, st.width)); + } + let remaining = kept.len(); + *strokes = kept; + if remaining == 0 { + list.remove(pos); + } + for (preset, xfrm, color, width) in made { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?); + let name = format!( + "{} {}", + match preset { + "line" => "Straight Connector", + "rect" => "Rectangle", + "ellipse" => "Oval", + _ => "Isosceles Triangle", + }, + id.0 + ); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + list.push(Shape { + id, + name, + xfrm: Some(xfrm), + geom: Geom::preset(preset), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), + kind: ShapeKind::Shape, + ..Default::default() + }); + new.push(id); + } + sel.shapes = new.clone(); + Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use serde_json::json; + + fn rotation(s: &Session) -> f64 { + let st = s.active().unwrap(); + let id = st.selection.shapes[0]; + st.shape(id).unwrap().xfrm.unwrap().rot + } + + #[test] + fn a_twist_is_one_undo_and_wraps_past_a_full_turn() { + let mut s = Session::with_new(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap(); + let id = id.get("id").and_then(Value::as_u64).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01); + + s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s)); + s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation"); + assert!(s.active().unwrap().interaction.is_none()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + } + + #[test] + fn ending_a_twist_does_not_commit_a_drag() { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap(); + let st = s.active_mut().unwrap(); + st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() }); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move"); + } +} diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..7b3384fb 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide), cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout), cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset), - cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes), + cmd!( + "slide.notes", + "Notes", + ["View"], + None, + "{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)", + has_slide, + notes + ), cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename), cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add), cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename), @@ -47,7 +55,15 @@ pub fn specs() -> Vec { ), cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all), cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move), - cmd!("slide.fromOutline", "Slides from Outline…", ["Insert", "Slides From"], None, "{text}", has_doc, from_outline), + cmd!( + "slide.fromOutline", + "Slides from Outline…", + ["Insert", "Slides From"], + None, + "{text, markdown?: bool (headings are slides, list items bullets)}", + has_doc, + from_outline + ), ] } @@ -99,7 +115,7 @@ fn new(s: &mut Session, p: &Value) -> Result { } } }; - let mut slide = defaults::new_slide(doc, layout); + let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("slide.new", NO_ID_SPACE))?; if let Some(t) = str_param(p, "title") && let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())) { @@ -108,7 +124,12 @@ fn new(s: &mut Session, p: &Value) -> Result { if let Some(b) = str_param(p, "body") && let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title())) { - sh.text = Some(TextBody::from_text(b)); + let mut body = TextBody::from_text(b); + // Leading tabs set the outline level, as in text.set. + for para in &mut body.paragraphs { + super::text::apply_leading_tabs(para); + } + sh.text = Some(body); } let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len()); let id = slide.id; @@ -165,25 +186,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { for (k, i) in indices.iter().enumerate() { let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue }; let mut copy = src; - copy.id = SlideId(doc.alloc_id()); - super::edit::reid(doc, &mut copy.shapes); - // Animations follow the new shape ids by position. - let old_ids: Vec<_> = doc - .slides - .get(*i) - .map(|x| { - let mut v = vec![]; - deckcraft_model::walk(&x.shapes, &mut |sh, _| v.push(sh.id)); - v - }) - .unwrap_or_default(); - let mut new_shape_ids = vec![]; - deckcraft_model::walk(©.shapes, &mut |sh, _| new_shape_ids.push(sh.id)); - for a in &mut copy.animations { - if let Some(pos) = old_ids.iter().position(|x| *x == a.shape) { - a.shape = new_shape_ids.get(pos).copied().unwrap_or(a.shape); - } - } + copy.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.duplicate", NO_ID_SPACE))?); + // Animations follow the new shape ids, triggers included. + let map = super::edit::reid("slide.duplicate", doc, &mut copy.shapes)?; + super::edit::remap_animations(&mut copy.animations, &map); let section = doc.section_of(src_id(doc, *i)); let at = (last + 1 + k).min(doc.slides.len()); new_ids.push(copy.id); @@ -283,9 +289,16 @@ fn go_to(s: &mut Session, i: usize) -> Result { fn go(s: &mut Session, p: &Value) -> Result { let i = match (usize_param(p, "index"), p.get("id").and_then(Value::as_u64)) { (Some(i), _) => i, - (None, Some(id)) => s.doc()?.doc.slide_index(SlideId(u32::try_from(id).unwrap_or(0))).ok_or_else(|| bad("slide.go", "no such slide"))?, + (None, Some(id)) => s + .doc()? + .doc + .slide_index(SlideId(u32::try_from(id).map_err(|_| bad("slide.go", "no such slide"))?)) + .ok_or_else(|| bad("slide.go", "no such slide"))?, _ => return Err(bad("slide.go", "missing `index`")), }; + if i >= s.doc()?.doc.slides.len() { + return Err(bad("slide.go", format!("no slide {i}"))); + } go_to(s, i) } fn next(s: &mut Session, _p: &Value) -> Result { @@ -364,7 +377,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?; let mut fresh = vec![]; for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) { - let id = deckcraft_model::ShapeId(doc.alloc_id()); + let id = deckcraft_model::ShapeId(doc.alloc_id().ok_or_else(|| bad("slide.layout", NO_ID_SPACE))?); fresh.push((id, ls.clone())); } let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?; @@ -463,10 +476,20 @@ fn reset(s: &mut Session, p: &Value) -> Result { fn notes(s: &mut Session, p: &Value) -> Result { let i = index_of(s, p)?; + // Formatted notes as a whole (the notes editors), or text: then only what was retyped + // changes, so bold, levels and bullets around it survive. + let body = match p.get("body") { + Some(b) => Some(serde_json::from_value::(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?), + None => None, + }; let text = str_param(p, "text").unwrap_or("").to_string(); s.edit(|doc, _| { if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).notes = TextBody::from_text(&text); + let notes = &mut Arc::make_mut(sl).notes; + match body { + Some(b) => *notes = b, + None => deckcraft_model::edit::replace_text(notes, &text), + } } Ok(()) })?; @@ -572,10 +595,11 @@ fn section_move(s: &mut Session, p: &Value) -> Result { fn from_outline(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or("").to_string(); + let markdown = bool_param(p, "markdown").unwrap_or(false); s.edit(|doc, sel| { let before = doc.slides.len(); - let n = deckcraft_format::outline_to_slides(doc, &text); - // outline_to_slides appends; move them after the current slide. + let n = if markdown { deckcraft_format::markdown_to_slides(doc, &text) } else { deckcraft_format::outline_to_slides(doc, &text) }; + // both append; move them after the current slide. let at = (sel.slide + 1).min(before); let new: Vec<_> = doc.slides.drain(before..).collect(); for (k, sl) in new.into_iter().enumerate() { @@ -584,3 +608,24 @@ fn from_outline(s: &mut Session, p: &Value) -> Result { Ok(json!({"slides": n})) }) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn new_slide_body_leading_tabs_become_levels() { + let mut s = Session::with_new(); + let text = "Level one\n\tLevel two\n\t\tLevel three\nLevel one again"; + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Indent", "body": text})).expect("new slide"); + let st = s.doc().expect("doc"); + let slide = st.doc.slides.get(st.selection.slide).expect("slide"); + let body = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| !k.is_title())).expect("body"); + let paras = &body.text.as_ref().expect("text").paragraphs; + let got: Vec<(u8, String)> = paras.iter().map(|p| (p.level, p.text())).collect(); + let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")]; + assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::>()); + } +} diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs new file mode 100644 index 00000000..a483197c --- /dev/null +++ b/crates/engine/src/cmd/smartart.rs @@ -0,0 +1,2163 @@ +//! SmartArt: a diagram's outline (the Text Pane) and the shapes generated from it. +//! +//! A SmartArt graphic is a group whose `diagram` holds its layout and its nodes (text + outline +//! level). Every edit changes the nodes and regenerates the group's children inside the group's +//! child rectangle, so the diagram keeps its size and place. Text typed straight into a diagram +//! shape is read back into the nodes before each regeneration. + +use deckcraft_color::SchemeSlot; +use deckcraft_geom::Xfrm; +use deckcraft_model::style::{Gradient, GradientShape, GradientStop, Shadow}; +use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody}; +use deckcraft_model::{ + ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Line, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, +}; +use serde_json::{Value, json}; + +use super::*; +use crate::{EngineError, Result, Session}; + +/// Deepest outline level the Text Pane allows. +pub const MAX_LEVEL: u8 = 8; +/// Most top-level shapes a layout draws. +const MAX_SHAPES: usize = 40; + +/// DeckCraft's layouts, with the names PowerPoint's gallery gives them. +pub const LAYOUTS: [(&str, &str); 45] = [ + ("list", "Vertical Bullet List"), + ("blockList", "Basic Block List"), + ("hBulletList", "Horizontal Bullet List"), + ("process", "Basic Process"), + ("chevron", "Basic Chevron Process"), + ("verticalProcess", "Vertical Process"), + ("stepUp", "Step Up Process"), + ("cycle", "Basic Cycle"), + ("blockCycle", "Block Cycle"), + ("radial", "Basic Radial"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Basic Pyramid"), + ("invertedPyramid", "Inverted Pyramid"), + ("matrix", "Basic Matrix"), + ("titledMatrix", "Titled Matrix"), + ("pictureList", "Vertical Picture List"), + ("pictureCaption", "Picture Caption List"), + ("pictureProcess", "Picture Accent Process"), + ("pictureHList", "Horizontal Picture List"), + ("timeline", "Basic Timeline"), + ("venn", "Basic Venn"), + ("target", "Basic Target"), + ("textCycle", "Text Cycle"), + ("continuousCycle", "Continuous Cycle"), + ("horizontalHierarchy", "Horizontal Hierarchy"), + ("gridMatrix", "Grid Matrix"), + ("gear", "Gear"), + ("funnel", "Funnel"), + ("segmentedCycle", "Segmented Cycle"), + ("multidirectionalCycle", "Multidirectional Cycle"), + ("orgChart", "Organization Chart"), + ("halfCircleOrgChart", "Half Circle Organization Chart"), + ("cycleMatrix", "Cycle Matrix"), + ("segmentedPyramid", "Segmented Pyramid"), + ("pie", "Basic Pie"), + ("nameTitleOrgChart", "Name and Title Organization Chart"), + ("verticalBoxList", "Vertical Box List"), + ("trapezoidList", "Trapezoid List"), + ("tableList", "Table List"), + ("accentProcess", "Accent Process"), + ("continuousBlockProcess", "Continuous Block Process"), + ("bendingProcess", "Basic Bending Process"), + ("linearVenn", "Linear Venn"), + ("stackedVenn", "Stacked Venn"), + ("nestedTarget", "Nested Target"), +]; +/// SmartArt Styles. +pub const STYLES: [(&str, &str); 9] = [ + ("flat", "Simple Fill"), + ("subtle", "Subtle Effect"), + ("intense", "Intense Effect"), + ("outline", "White Outline"), + ("polished", "Polished"), + ("inset", "Inset"), + ("cartoon", "Cartoon"), + ("powder", "Powder"), + ("brick", "Brick Scene"), +]; +/// Change Colors. +pub const COLORS: [(&str, &str); 17] = [ + ("colorful", "Colorful"), + ("range23", "Colorful Range - Accent Colors 2 to 3"), + ("range34", "Colorful Range - Accent Colors 3 to 4"), + ("range45", "Colorful Range - Accent Colors 4 to 5"), + ("range56", "Colorful Range - Accent Colors 5 to 6"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ("accent1Grad", "Gradient Range - Accent 1"), + ("accent2Grad", "Gradient Range - Accent 2"), + ("accent3Grad", "Gradient Range - Accent 3"), + ("accent4Grad", "Gradient Range - Accent 4"), + ("accent5Grad", "Gradient Range - Accent 5"), + ("accent6Grad", "Gradient Range - Accent 6"), +]; + +/// The gallery category a layout is shown under in Convert to SmartArt and Change Layout. +pub fn category(kind: &str) -> &'static str { + match kind { + "pictureList" | "pictureCaption" | "pictureProcess" | "pictureHList" => "picture", + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "cycle" | "blockCycle" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "pie" => "cycle", + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", + "radial" | "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", + "matrix" | "titledMatrix" | "gridMatrix" | "cycleMatrix" => "matrix", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + _ => "list", + } +} + +/// A layout's gallery picture: the graphic drawn with sample nodes (or `nodes`) in a 320 × 200 pt +/// group at the origin, in the style and colours given, with no text in the samples. +pub fn preview(kind: &str, style: &str, colors: &str, nodes: Option<&[DiagramNode]>) -> Shape { + let sample = |levels: &[u8]| -> Vec { levels.iter().map(|l| DiagramNode { level: *l, ..Default::default() }).collect() }; + let nodes = match nodes { + Some(n) if !n.is_empty() => n.to_vec(), + _ => match category(kind) { + "hierarchy" => sample(&[0, 1, 2, 2, 1]), + "matrix" => sample(&[0, 0, 0, 0, 0]), + "list" if kind != "blockList" && kind != "tableList" => sample(&[0, 1, 0, 1, 0, 1]), + _ => sample(&[0, 0, 0, 0]), + }, + }; + let d = Diagram { kind: kind.into(), nodes, style: style.into(), colors: colors.into(), ..Default::default() }; + let area = Xfrm::new(0.0, 0.0, 320.0, 200.0); + let mut children = layout(&d, area); + for (k, c) in children.iter_mut().enumerate() { + c.id = ShapeId(10_000 + k as u32); + } + Shape { + id: ShapeId(9_999), + name: "SmartArt preview".into(), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + ..Default::default() + } +} + +pub fn is_kind(k: &str) -> bool { + LAYOUTS.iter().any(|(id, _)| *id == k) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "smartArt.nodes", + "Text Pane", + ["SmartArt Design", "Create Graphic"], + None, + "{id?} → {id, kind, nodes: [{text, level}]} (id: the SmartArt group or one of its shapes; default the selection)", + has_selection, + nodes + ), + cmd!( + "smartArt.setNodes", + "Edit SmartArt Text", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?, title?} (index + text edits one node; title: its Name and Title box)", + has_selection, + set_nodes + ), + cmd!( + "smartArt.addNode", + "Add Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index?, position?: after|before|below|above|assistant, text?} → {index} (default: after the last node)", + has_selection, + add_node + ), + cmd!( + "smartArt.removeNode", + "Remove Shape", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index} (its sub-points move up one level)", + has_selection, + remove_node + ), + cmd!("smartArt.promote", "Promote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, promote), + cmd!("smartArt.demote", "Demote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, demote), + cmd!( + "smartArt.layout", + "Change Layout", + ["SmartArt Design", "Layouts"], + None, + "{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie|nameTitleOrgChart|verticalBoxList|trapezoidList|tableList|accentProcess|continuousBlockProcess|bendingProcess|linearVenn|stackedVenn|nestedTarget}", + has_selection, + change_layout + ), + cmd!( + "smartArt.fromText", + "Convert to SmartArt", + ["Home", "Paragraph"], + None, + "{id?, kind?} → {id} (a text box or placeholder becomes a SmartArt graphic in its place: one node per paragraph, indent levels kept)", + has_selection, + from_text + ), + cmd!( + "smartArt.toText", + "Convert to Text", + ["SmartArt Design", "Reset"], + None, + "{id?} → {id} (the graphic becomes a text box of its outline, bulleted by level)", + has_selection, + to_text + ), + cmd!( + "smartArt.toShapes", + "Convert to Shapes", + ["SmartArt Design", "Reset"], + None, + "{id?} (the graphic becomes a plain group of its shapes; the outline is dropped)", + has_selection, + to_shapes + ), + cmd!( + "smartArt.picture", + "Insert Picture in SmartArt", + ["SmartArt Design", "Create Graphic"], + None, + "{id?, index, data?: base64 | path?: string | reset?: bool} (the node's picture placeholder in picture layouts)", + has_selection, + set_picture + ), + cmd!( + "smartArt.style", + "SmartArt Styles", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}", + has_selection, + change_style + ), + cmd!( + "smartArt.colors", + "Change Colors", + ["SmartArt Design", "SmartArt Styles"], + None, + "{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}", + has_selection, + change_colors + ), + ] +} + +/// Nodes from text lines: each leading tab is one outline level. +pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { + lines + .into_iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = l.chars().take_while(|c| *c == '\t').count(); + DiagramNode { + text: l.trim().to_string(), + level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), + picture: None, + assistant: false, + title: String::new(), + } + }) + .collect() +} + +/// Levels made consistent: the first node is top level and no node is more than one level +/// deeper than the node before it. +pub fn normalize(nodes: &mut [DiagramNode]) { + let mut prev: Option = None; + for n in nodes.iter_mut() { + n.level = match prev { + None => 0, + Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), + }; + prev = Some(n.level); + } +} + +/// Index one past the last descendant of node `i`. +fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { + let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); + let mut j = i + 1; + while nodes.get(j).is_some_and(|n| n.level > lv) { + j += 1; + } + j +} + +/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the +/// new node's index. +pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { + let Some(i) = index.filter(|i| *i < nodes.len()) else { + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); + return nodes.len() - 1; + }; + let lv = nodes[i].level; + let (at, level) = match position { + "before" => (i, lv), + "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), + "above" => { + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level = (n.level + 1).min(MAX_LEVEL); + } + (i, lv) + } + "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), + _ => (subtree_end(nodes, i), lv), + }; + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); + at +} + +/// Remove node `i`; its sub-points move up one level. +pub fn remove(nodes: &mut Vec, i: usize) -> bool { + if i >= nodes.len() { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i + 1..end] { + n.level = n.level.saturating_sub(1); + } + nodes.remove(i); + normalize(nodes); + true +} + +/// Move node `i` and its sub-points one level up. False when it is already top level. +pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + if nodes.get(i).is_none_or(|n| n.level == 0) { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level -= 1; + } + true +} + +/// Move node `i` and its sub-points one level down, under the node before it. False when there is +/// nothing to go under. +pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; + if cur.level > prev.level || cur.level >= MAX_LEVEL { + return false; + } + let end = subtree_end(nodes, i); + if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { + return false; + } + for n in &mut nodes[i..end] { + n.level += 1; + } + true +} + +/// A generated shape and the nodes whose text it shows (none for arrows and lines). +type Item = (Shape, Vec); + +/// Top-level nodes, each with its sub-points. +fn groups(d: &Diagram) -> Vec> { + let mut out: Vec> = vec![]; + for (i, n) in d.nodes.iter().enumerate() { + match out.last_mut() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + out.truncate(MAX_SHAPES); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +fn sync_text(d: &mut Diagram, children: &[Shape]) { + if d.imported { + return; + } + let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); + for (shape, idx) in children.iter().zip(map) { + let Some(body) = &shape.text else { continue }; + if idx.is_empty() || body.paragraphs.len() != idx.len() { + continue; + } + for (p, i) in body.paragraphs.iter().zip(idx) { + if let Some(n) = d.nodes.get_mut(i) { + n.text = p.text(); + } + } + } +} + +fn slot(i: usize) -> SchemeSlot { + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] +} + +/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` +/// keeps one. +fn accent(d: &Diagram, i: usize) -> SchemeSlot { + let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); + if let Some(r) = d.colors.strip_prefix("range") + && let Some(a @ 1..=6) = digit(r, 0) + { + // Colorful Range: alternate between two neighbouring accents. + return slot(a - 1 + i % 2); + } + match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { + Some(n @ 1..=6) => slot(n - 1), + _ => slot(i), + } +} + +/// Gradient Range: each shape a step darker than the one before (the steps cycle). +fn shade(d: &Diagram, i: usize) -> Option { + d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) +} + +/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) +/// lit by the three-point rig. +fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { + format!( + "", + (w * 12700.0).round() as i64, + (h * 12700.0).round() as i64 + ) +} + +/// Brick Scene's camera: the diagram's shapes seen from above and to the side. +pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; + +/// Whether `style` is one of the 3-D SmartArt Styles. +pub fn is_3d(style: &str) -> bool { + matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") +} + +/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet +/// boxes of the bullet lists) are a pale tint of the accent with dark text. +fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { + let a = accent(d, i); + let base = match shade(d, i) { + Some(t) => ColorRef::scheme(a).with(t), + None => ColorRef::scheme(a), + }; + let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); + let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); + sh.style = Some(ShapeStyle::accent(a)); + match d.style.as_str() { + _ if light => { + sh.fill = Some(Fill::solid(tint(80))); + sh.line = Some(Line::solid(tint(60), 1.0)); + } + "outline" => { + sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + sh.line = Some(Line::solid(base.clone(), 2.25)); + } + "subtle" => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); + } + "polished" | "inset" | "cartoon" | "powder" | "brick" => { + let (bevel, bw, line, fill) = match d.style.as_str() { + "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), + "inset" => ("relaxedInset", 10.0, None, base.clone()), + "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), + "powder" => ("convex", 14.0, None, tint(40)), + _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), + }; + sh.fill = Some(Fill::solid(fill)); + sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); + sh.effects = Some(Effects { + outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), + bevel: Some(bevel.into()), + raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), + ..Default::default() + }); + } + "intense" => { + sh.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: tint(30) }, + GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); + } + _ => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + } + if let Some(body) = &mut sh.text { + let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } +} + +fn shadow(blur: f64, dist: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With +/// `title` false every line is a bullet (the bullet boxes of the bullet lists). +fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { + let mut body = TextBody::default(); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); + for (k, n) in lines.iter().enumerate() { + let mut para = Paragraph::new(&n.text); + let head = title && k == 0; + if head { + para.props.align = Some(Align::Center); + } else { + para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); + para.props.align = Some(Align::Left); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-10.0); + } + let size = if head { 20.0 } else { 14.0 }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + if !title { + body.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } +} + +/// A plain shape with no text (arrows between steps). +fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { + let mut x = x; + x.rot = rot; + Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } +} + +fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { + let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); + x.flip_h = (b.0 < a.0) != (b.1 < a.1); + Shape { + name: "SmartArt connector".into(), + xfrm: Some(x), + geom: Geom::preset("line"), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), + ..Default::default() + } +} + +/// The diagram's shapes laid out in `area` (ids not yet assigned). +pub fn layout(d: &Diagram, area: Xfrm) -> Vec { + layout_items(d, area).into_iter().map(|(s, _)| s).collect() +} + +/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is +/// shrunk per shape so its longest word fits on a line. +fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout_raw(d, area); + for (sh, _) in &mut v { + fit_words(sh); + } + v +} + +/// Rough advance of a character as a fraction of the font size (a sans text face). +const CHAR_W: f64 = 0.6; +/// Smallest font scale shrinking to fit goes to. +const MIN_SCALE: f64 = 0.25; + +/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so +/// no word is wider than its line and would break mid-word. Overflow in height is left to the +/// body's shrink-on-overflow autofit. +fn fit_words(sh: &mut Shape) { + let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + // Round shapes keep their text in the inscribed rectangle (about 71% of the width). + let text_w = match sh.geom.preset_name() { + Some("ellipse" | "gear6" | "gear9") => w * 0.71, + _ => w, + }; + let Some(body) = &mut sh.text else { return }; + let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let mut scale: f64 = 1.0; + for p in &body.paragraphs { + let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); + for r in &p.runs { + let size = r.props.size.unwrap_or(18.0); + let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; + let need = word * size * CHAR_W; + if need > 0.0 { + scale = scale.min(line.max(0.0) / need); + } + } + } + let scale = scale.max(MIN_SCALE); + if scale >= 0.999 { + return; + } + let shrink = |v: &mut Option| { + if let Some(x) = v { + *x = (*x * scale * 2.0).floor() / 2.0; + } + }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + shrink(&mut r.props.size); + } + shrink(&mut p.end_props.size); + shrink(&mut p.props.margin_left); + if let Some(i) = &mut p.props.indent { + *i *= scale; + } + } +} + +/// A picture placeholder for node `node`: its picture when it has one, else a pale box. +fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { + let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; + paint(d, &mut sh, i, true); + if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { + sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + sh +} + +fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { + let gs = groups(d); + let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; + let n = gs.len().max(1); + let nf = n as f64; + let mut out: Vec = vec![]; + // A titled shape for top-level group `i`, painted in its accent. + let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { + let mut sh = text_shape(preset, x, &nodes(g), true); + paint(d, &mut sh, i, false); + out.push((sh, g.to_vec())); + }; + let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { + let mut sh = deco("rightArrow", x, rot); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + }; + match d.kind.as_str() { + "hierarchy" => return hierarchy(d, area, false), + "horizontalHierarchy" => return hierarchy(d, area, true), + "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), + "list" | "hBulletList" => { + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + ( + Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), + (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), + ) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } + } + "blockList" => { + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; + titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } + } + "process" | "chevron" => { + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } + } + "verticalProcess" => { + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } + } + "stepUp" => { + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } + } + "cycle" | "blockCycle" => { + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + titled( + &mut out, + if block { "roundRect" } else { "ellipse" }, + Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), + i, + g, + ); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } + } + } + "radial" => { + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return out }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); + } + } + "pyramid" | "invertedPyramid" => { + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "matrix" | "titledMatrix" => { + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); + } + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); + } + } + "pictureList" => { + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } + } + "pictureCaption" | "pictureProcess" => { + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } + "textCycle" | "continuousCycle" => { + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + arrow( + &mut out, + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), + (i as f64 + 0.5) / nf * 360.0, + i, + ); + } + } + out.push((sh, g.clone())); + } + } + "segmentedCycle" | "pie" => { + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "multidirectionalCycle" => { + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = deco( + "leftRightArrow", + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), + (i as f64 + 0.5) / nf * 360.0, + ); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } + } + "cycleMatrix" => { + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } + } + "segmentedPyramid" => { + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: tw, + h: th, + d: path, + fill: deckcraft_geom::preset::FillMode::Norm, + stroke: true, + }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } + } + "verticalBoxList" => { + // Vertical Box List: a pale box per node with its sub-points, its title on a tab + // overlapping the box's top left. + let h = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let tab_h = (h * 0.3).min(40.0); + let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); + paint(d, &mut bx, i, true); + if let Some(b) = &mut bx.text { + b.body.inset_t = Some(tab_h * 0.6); + } + out.push((bx, g[1..].to_vec())); + titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); + } + } + "trapezoidList" | "tableList" => { + // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the + // first node as a header bar over a column per other node. + let table = d.kind == "tableList"; + let (head, cols): (Option<&Vec>, &[Vec]) = + if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; + let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; + if let Some(h) = head { + titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); + } + let m = cols.len().max(1) as f64; + let w = area.w / m; + for (k, g) in cols.iter().enumerate() { + let i = k + usize::from(table); + let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); + let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + if !table { + b.body.inset_l = Some(x.w * 0.2); + b.body.inset_r = Some(x.w * 0.2); + b.body.inset_t = Some(x.h * 0.08); + } + } + paint(d, &mut sh, i, table); + out.push((sh, g.clone())); + } + } + "accentProcess" => { + // Accent Process: a title box per step with its sub-points in a pale box below and to the + // right, arrows between the titles. + let gap = area.w * 0.05; + let w = (area.w - gap * (nf - 1.0)) / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + let th = area.h * 0.22; + if g.len() > 1 { + let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(b) = &mut sh.text { + b.body.inset_t = Some(th * 0.45); + } + out.push((sh, g[1..].to_vec())); + } + titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); + if i + 1 < n { + let a = gap * 0.8; + arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } + "continuousBlockProcess" => { + // Continuous Block Process: one big pale arrow behind a row of boxes. + let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); + paint(d, &mut big, 0, true); + out.push((big, vec![])); + let w = area.w * 0.85 / nf; + let h = area.h * 0.32; + for (i, g) in gs.iter().enumerate() { + let x = area.x + area.w * 0.02 + i as f64 * w; + titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); + } + } + "bendingProcess" => { + // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. + let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let x = area.x + c as f64 * cw; + let y = area.y + r as f64 * rh + (rh - bh) / 2.0; + titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); + if i + 1 < n { + let a = (cw - bw) * 0.7; + if c + 1 < cols { + arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); + } else { + // The bend: down from the row's last box towards the next row's first. + let a = (rh - bh).min(a * 1.5).max(6.0); + arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); + } + } + } + } + "linearVenn" | "stackedVenn" => { + // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, + // the text near each one's top. + let linear = d.kind == "linearVenn"; + if linear { + let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); + let step = dc * 0.75; + let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; + for (i, g) in gs.iter().enumerate() { + let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(55_000)); + } + out.push((sh, g.clone())); + } + } else { + let s = area.h.min(area.w); + let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); + for (i, _) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + // Each circle's text in the band between its top and the next circle's. + let band = s / nf; + for (i, g) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let top = bottom - dc; + let h = if i + 1 < n { band } else { dc }; + let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; + let mut t = + text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + } + "nestedTarget" => { + // Nested Target: rounded boxes nested inside each other, the text on each one's top band. + let band_x = area.w / 2.0 / nf * 0.6; + let band_y = area.h / nf * 0.9 / 2.0; + for (i, _) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; + let mut t = text_shape( + "rect", + Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), + &nodes(g), + true, + ); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "gridMatrix" => { + // Grid Matrix: four quadrants on crossed axes. + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] + { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + titled(&mut out, "rect", x, i, g); + } + } + "gear" => { + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } + } + "funnel" => { + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (&gs[..], None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } + } + "pictureHList" => { + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "timeline" => { + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } + } + "venn" => { + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.36; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } + } + "target" => { + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } + } + _ => {} + } + out +} + +/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on +/// a row of their own between the parent and its other children. `half_circle` draws each node as +/// its text in an open half ring (Half Circle Organization Chart). +fn org_chart(d: &Diagram, area: Xfrm) -> Vec { + let half_circle = d.kind == "halfCircleOrgChart"; + let name_title = d.kind == "nameTitleOrgChart"; + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. + let mut slot = vec![0usize; nodes.len()]; + for i in 0..nodes.len() { + slot[i] = match parent[i] { + None => 0, + Some(p) if asst(i) => slot[p] + 1, + Some(p) => slot[p] + 2, + }; + } + let mut used: Vec = slot.clone(); + used.sort_unstable(); + used.dedup(); + let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); + let rows = used.len().max(1); + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + if asst(i) { + continue; + } + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let mut side = vec![0usize; nodes.len()]; + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; + match parent[i] { + Some(p) if asst(i) => { + // Assistants alternate right and left of the parent's trunk. + let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); + side[i] = k; + let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; + let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; + let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; + Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) + } + _ => { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, y, bw, bh) + } + } + }) + .collect(); + let mut out: Vec = vec![]; + for i in 0..nodes.len() { + let Some(p) = parent[i] else { continue }; + let (a, b) = (boxes[p], boxes[i]); + let pc = a.x + a.w / 2.0; + if asst(i) { + let my = b.y + b.h / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); + let ex = if b.x > pc { b.x } else { b.x + b.w }; + out.push((line_shape((pc, my), (ex, my)), vec![])); + } else { + let mid = b.y - (row_h - bh) / 2.0; + let cc = b.x + b.w / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); + out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); + out.push((line_shape((cc, mid), (cc, b.y)), vec![])); + } + } + let _ = side; + for (i, n) in nodes.iter().enumerate() { + let depth = slot[i] / 2; + if half_circle { + // Two half rings round the text: the upper left one in the accent, the lower right one + // in its tint. + let x = boxes[i]; + for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { + let mut arc = deco("blockArc", x, 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; + paint(d, &mut arc, depth, false); + if k == 1 { + let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); + arc.fill = Some(Fill::solid(c)); + } + arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + arc.effects = None; + out.push((arc, vec![])); + } + let mut t = text_shape("rect", x, &[n], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, vec![i])); + } else { + let mut x = boxes[i]; + if name_title { + // The name in the upper part; the title box overlaps the lower edge. + x.h *= 0.85; + } + let mut sh = text_shape("rect", x, &[n], true); + paint(d, &mut sh, depth, false); + out.push((sh, vec![i])); + if name_title { + let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); + let title = DiagramNode { text: n.title.clone(), ..Default::default() }; + let mut t = text_shape("rect", tb, &[&title], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + p.end_props.size = Some(11.0); + } + } + out.push((t, vec![])); + } + } + } + out +} + +/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. +fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + let depth: Vec = (0..nodes.len()) + .map(|i| { + let (mut d, mut p) = (0, parent[i]); + while let Some(q) = p { + d += 1; + p = parent[q]; + } + d + }) + .collect(); + let rows = depth.iter().max().copied().unwrap_or(0) + 1; + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. + let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; + let cell_w = span / total as f64; + let row_h = across / rows as f64; + let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; + // Left edge (in leaves) of each node's span. + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; + if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } + }) + .collect(); + let mut out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + let mut sh = text_shape("roundRect", boxes[i], &[n], true); + paint(d, &mut sh, depth[i], false); + (sh, vec![i]) + }) + .collect(); + for i in 0..nodes.len() { + if let Some(p) = parent[i] { + let (a, b) = (boxes[p], boxes[i]); + let (p, q) = if horizontal { + ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) + } else { + ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) + }; + out.push((line_shape(p, q), vec![])); + } + } + out +} + +/// The SmartArt group that `id` is, or is inside. +fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + for sh in shapes { + if sh.id == id && sh.diagram.is_some() { + return Some(sh); + } + if let ShapeKind::Group { children, .. } = &sh.kind { + if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { + return Some(sh); + } + if let Some(g) = diagram_group(children, id) { + return Some(g); + } + } + } + None +} + +/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. +pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; + let ids = targets(s, p)?; + ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) +} + +/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. +fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result> { + let mut v = layout(d, area); + for sh in &mut v { + sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + } + Ok(v) +} + +/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. +fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { + let gid = target_group(s, p, cmd)?; + s.edit(|doc, sel| { + let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; + let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; + sync_text(&mut d, &children); + let out = f(&mut d)?; + normalize(&mut d.nodes); + d.imported = false; + let kids = regenerate(cmd, doc, &d, child)?; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; + sh.kind = ShapeKind::Group { children: kids, child }; + sh.diagram = Some(d); + sel.shapes = vec![gid]; + sel.text = None; + Ok(out) + }) +} + +fn index(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) +} + +/// Insert a new SmartArt graphic (`insert.smartArt`). +pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { + let kind = if is_kind(kind) { kind } else { "process" }; + if nodes.is_empty() { + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); + } + normalize(&mut nodes); + let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let id = s.edit(|doc, sel| { + let children = regenerate("insert.smartArt", doc, &d, area)?; + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?); + let g = Shape { + id, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +fn nodes(s: &mut Session, p: &Value) -> Result { + let gid = target_group(s, p, "smartArt.nodes")?; + let g = shape_of(s, gid)?; + let mut d = g.diagram.unwrap_or_default(); + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) +} + +fn set_nodes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.setNodes"; + let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { + (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), + (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), + _ => None, + }; + let one = p.get("index").map(|_| index(p, CMD)).transpose()?; + let text = str_param(p, "text").map(String::from); + let title = str_param(p, "title").map(String::from); + edit_diagram(s, p, CMD, |d| { + if let Some(n) = given { + d.nodes = n; + } + if let (Some(i), Some(t)) = (one, text) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; + } + if let (Some(i), Some(t)) = (one, title) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; + } + Ok(Value::Null) + }) +} + +fn add_node(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + let pos = str_param(p, "position").unwrap_or("after").to_string(); + let text = str_param(p, "text").unwrap_or("").to_string(); + edit_diagram(s, p, "smartArt.addNode", |d| { + let at = add(&mut d.nodes, i, &pos, &text); + Ok(json!({"index": at})) + }) +} + +fn remove_node(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.removeNode"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) +} + +fn promote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.promote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) +} + +fn demote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.demote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) +} + +/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in +/// the shape's place. +fn from_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.fromText"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); + let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; + let (area, nodes) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; + let body = + sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; + let mut nodes: Vec = body + .paragraphs + .iter() + .filter(|p| !p.text().trim().is_empty()) + .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) + .collect(); + normalize(&mut nodes); + // A small text box grows to a size a diagram can use, keeping its top left on the slide. + let mut a = xfrm_of(&st.doc, &st.selection, sh); + let size = st.doc.slide_size; + a.w = a.w.max(size.width * 0.5).min(size.width); + a.h = a.h.max(size.height * 0.35).min(size.height); + a.x = a.x.min(size.width - a.w).max(0.0); + a.y = a.y.min(size.height - a.h).max(0.0); + a.rot = 0.0; + (a, nodes) + }; + let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; + let gid = s.edit(|doc, sel| { + let children = regenerate(CMD, doc, &d, area)?; + let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let g = Shape { + id: gid, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; + // In the text shape's place in the z-order. + shapes[at] = g; + sel.shapes = vec![gid]; + sel.text = None; + Ok(gid) + })?; + Ok(json!({"id": gid.0})) +} + +/// Convert to Text: a text box with the outline, one bulleted paragraph per node. +fn to_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toText"; + let gid = target_group(s, p, CMD)?; + let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). + let (title_pt, bullet_pt) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + text_sizes(g.children()) + }; + let new = s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; + let area = shapes[at].xfrm.unwrap_or_default(); + let mut body = TextBody::default(); + for n in &d.nodes { + let mut para = Paragraph::new(&n.text); + para.level = n.level.min(MAX_LEVEL); + let size = if n.level == 0 { title_pt } else { bullet_pt }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-14.0); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + body.body.wrap = Some(true); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let tb = + Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + shapes[at] = tb; + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": new.0})) +} + +/// Convert to Shapes: keep the drawing as an ordinary group. +fn to_shapes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toShapes"; + let gid = target_group(s, p, CMD)?; + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; + g.diagram = None; + g.name = "Group".into(); + sel.shapes = vec![gid]; + Ok(()) + })?; + Ok(Value::Null) +} + +/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). +fn text_sizes(children: &[Shape]) -> (f64, f64) { + let mut titles: Vec = vec![]; + let mut bullets: Vec = vec![]; + for c in children { + let Some(b) = &c.text else { continue }; + for p in &b.paragraphs { + let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; + if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } + } + } + let mode = |v: &mut Vec, d: f64| { + v.sort_by(|a, b| a.total_cmp(b)); + let mut best = (d, 0usize); + let mut k = 0; + while k < v.len() { + let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); + if j > best.1 { + best = (v[k], j); + } + k += j; + } + best.0 + }; + (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) +} + +/// The graphic `gid` with its outline (typed text synced). +fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { + let st = s.doc().ok()?; + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = diagram_group(shapes, gid)?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn set_picture(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.picture"; + let i = index(p, CMD)?; + let media = if bool_or(p, "reset", false) { + None + } else { + let (name, bytes) = super::insert::media_bytes(p, CMD)?; + let ct = super::insert::content_type(&name, &bytes); + target_group(s, p, CMD)?; + Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?) + }; + edit_diagram(s, p, CMD, |d| { + // A sub-point's picture is its top-level node's: that's the node the placeholder shows. + let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); + let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; + n.picture = media; + Ok(Value::Null) + }) +} + +fn change_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.style"; + let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.style != v { + d.style_xml = None; + } + d.style = v; + Ok(Value::Null) + }) +} + +fn change_colors(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.colors"; + let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.colors != v { + d.colors_xml = None; + } + d.colors = v; + Ok(Value::Null) + }) +} + +fn change_layout(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.layout"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.kind != kind { + // PowerPoint's own layout no longer applies. + d.layout_id.clear(); + d.layout_xml = None; + } + d.kind = kind; + Ok(Value::Null) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lv(n: &[DiagramNode]) -> Vec { + n.iter().map(|x| x.level).collect() + } + fn mk(levels: &[u8]) -> Vec { + levels + .iter() + .enumerate() + .map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false, title: String::new() }) + .collect() + } + + #[test] + fn outline_edits() { + let mut n = mk(&[0, 1, 1, 0]); + assert_eq!(add(&mut n, Some(0), "after", "x"), 3, "after the node's sub-points"); + assert_eq!(lv(&n), [0, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "below", "y"), 3); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert_eq!(add(&mut n, Some(0), "above", "z"), 0); + assert_eq!(lv(&n), [0, 1, 2, 2, 2, 0, 0]); + assert!(remove(&mut n, 1)); + assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]); + assert!(!promote_node(&mut n, 0)); + assert!(!demote_node(&mut n, 0), "nothing above the first node"); + assert!(!demote_node(&mut n, 2) || lv(&n)[2] == 2); + let mut m = mk(&[0, 0, 1]); + assert!(demote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 1, 2], "sub-points move with their node"); + assert!(promote_node(&mut m, 1)); + assert_eq!(lv(&m), [0, 0, 1]); + let mut bad = mk(&[2, 4, 0]); + normalize(&mut bad); + assert_eq!(lv(&bad), [0, 1, 0]); + assert_eq!(lv(&nodes_from_lines(["A", "\tB", "\t\tC", "", "D"])), [0, 1, 2, 0]); + } + + #[test] + fn layouts_map_nodes_to_shapes() { + let area = Xfrm::new(0.0, 0.0, 800.0, 400.0); + let d = Diagram { kind: "blockList".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }; + let v = layout(&d, area); + assert_eq!(v.len(), 2); + let t = v[0].text.as_ref().map(|b| b.paragraphs.len()); + assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape"); + let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]), ..Default::default() }; + let v = layout(&h, area); + assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link"); + for sh in &v { + let x = sh.xfrm.unwrap_or_default(); + assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}"); + } + assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty()); + // Vertical Bullet List: a bar per top node, its sub-points in a box below. + let v = layout_items(&Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }, area); + let maps: Vec> = v.into_iter().map(|(_, m)| m).collect(); + assert_eq!(maps, [vec![0], vec![1, 2], vec![3]]); + } + + #[test] + fn every_layout_shows_every_node_inside_its_area() { + let area = Xfrm::new(50.0, 40.0, 800.0, 400.0); + for (kind, _) in LAYOUTS { + for style in std::iter::once("").chain(STYLES.iter().map(|(k, _)| *k)) { + let d = Diagram { + kind: kind.into(), + nodes: mk(&[0, 1, 0, 0, 1, 0]), + style: style.into(), + colors: "accent2".into(), + ..Default::default() + }; + let items = layout_items(&d, area); + let mut shown: Vec = items.iter().flat_map(|(_, m)| m.clone()).collect(); + shown.sort_unstable(); + // Gear draws three gears, as PowerPoint does: the fourth group isn't shown. + let want: Vec = (0..if kind == "gear" { 5 } else { 6 }).collect(); + assert_eq!(shown, want, "{kind}"); + for (sh, _) in &items { + let x = sh.xfrm.unwrap_or_default(); + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + assert!(x.w.is_finite() && x.h.is_finite() && x.w >= 0.0 && x.h >= 0.0, "{kind} {x:?}"); + assert!((49.0..=851.0).contains(&cx) && (39.0..=441.0).contains(&cy), "{kind} {x:?}"); + } + } + } + } + + #[test] + fn long_words_shrink_to_fit_their_shape() { + let d = Diagram { kind: "pyramid".into(), nodes: nodes_from_lines(["Internationalisation", "Build", "Test"]), ..Default::default() }; + let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 300.0)); + let size = |k: usize| items[k].0.text.as_ref().and_then(|b| b.paragraphs[0].runs[0].props.size).unwrap_or(0.0); + assert!(size(0) < 20.0 && size(0) >= 20.0 * MIN_SCALE - 0.5, "the tip's long word shrinks: {}", size(0)); + assert!((size(1) - 20.0).abs() < 1e-9, "short words keep their size"); + } + + #[test] + fn colour_variants_pick_accents() { + let d = |c: &str| Diagram { colors: c.into(), ..Default::default() }; + assert_eq!( + (accent(&d("range45"), 0), accent(&d("range45"), 1), accent(&d("range45"), 2)), + (SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent4) + ); + assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3); + assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none()); + } + + #[test] + fn assistants_sit_beside_their_parent() { + let mut v = mk(&[0, 1, 1]); + let a = add(&mut v, Some(0), "assistant", "Aide"); + assert_eq!((a, v[1].level, v[1].assistant), (1, 1, true)); + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 400.0)); + let boxed = |k: usize| items.iter().find(|(_, m)| m == &vec![k]).and_then(|(s, _)| s.xfrm).unwrap_or_default(); + let (ceo, aide, kid) = (boxed(0), boxed(1), boxed(2)); + assert!(aide.y > ceo.y && aide.y < kid.y, "the assistant's row is between its parent and the children"); + assert!(aide.x > ceo.x, "beside the parent's trunk"); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..1c5b080c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1,10 +1,11 @@ //! Table Design and Table Layout tabs. -use deckcraft_model::{ShapeKind, Table}; +use deckcraft_geom::{Affine, Point, Rect}; +use deckcraft_model::{Shape, ShapeKind, Table}; use serde_json::{Value, json}; use super::*; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ @@ -27,7 +28,15 @@ pub fn specs() -> Vec { s, p, "delCol" )), cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge), - cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split), + cmd!( + "table.split", + "Split Cells", + ["Table Layout", "Merge"], + None, + "{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}", + has_selection, + split + ), cmd!( "table.style", "Table Styles", @@ -42,7 +51,7 @@ pub fn specs() -> Vec { "Table Style Options", ["Table Design", "Table Style Options"], None, - "{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}", + "{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}", has_selection, options ), @@ -55,16 +64,167 @@ pub fn specs() -> Vec { has_selection, cell_fill ), + cmd!( + "table.borders", + "Borders", + ["Table Design", "Draw Borders"], + None, + "{which: all|outside|inside|insideH|insideV|top|bottom|left|right|diagDown|diagUp|none, color?, width?: pt, dash?: solid|dash|dot…, cells?: [[r,c],…] (default: selected cells or all), id?}", + has_selection, + borders + ), + cmd!( + "table.cellMargins", + "Cell Margins", + ["Table Layout", "Alignment"], + None, + "{preset?: normal|none|narrow|wide, left?, right?, top?, bottom?: pt, cells?, id?}", + has_selection, + cell_margins + ), + cmd!( + "table.cellAlign", + "Align Cell Text", + ["Table Layout", "Alignment"], + None, + "{anchor: top|middle|bottom, cells?, id?}", + has_selection, + cell_align + ), cmd!("table.distributeRows", "Distribute Rows", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(s, p, true)), cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute( s, p, false )), - cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width), + cmd!( + "table.columnWidth", + "Width", + ["Table Layout", "Cell Size"], + None, + "{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}", + has_selection, + column_width + ), cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height), + cmd!( + "table.drawLine", + "Draw Table", + ["Table Design", "Draw Borders"], + None, + "{from: [x, y], to: [x, y] slide pt, id?: table (default: the one under `from`)} splits the cells the line crosses", + has_slide, + draw_line + ), + cmd!( + "table.eraseLine", + "Table Eraser", + ["Table Design", "Draw Borders"], + None, + "{at: [x, y] slide pt, id?, tol?: pt (default 6)} merges the two cells on either side of the nearest border", + has_slide, + erase_line + ), cmd!(noundo "table.selectCells", "Select Cells", [], None, "{from: [r,c], to: [r,c], id?}", has_selection, select_cells), ] } +/// Narrowest a column gets when its border is dragged (points). +pub const MIN_COLUMN_WIDTH: f64 = 12.0; + +/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells. +#[derive(Clone, Debug)] +pub struct TableFrame { + /// Table-local (points from the table's top-left corner) → slide space, as the table is drawn. + pub affine: Affine, + pub cols: Vec, + /// Row heights as drawn: a stored height is a minimum, rows grow to fit their text. + pub heights: Vec, + /// Visible cells (merges honoured): (row, col, table-local rect). + pub cells: Vec<(usize, usize, Rect)>, +} + +/// A border between table rows or columns that can be dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableBorder { + /// The right edge of this column (the last one is the table's right edge). + Column(usize), + /// The bottom edge of this row (the last one is the table's bottom edge). + Row(usize), +} + +/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table). +pub fn table_frame(st: &DocState, sh: &Shape) -> Option { + let ShapeKind::Table(t) = &sh.kind else { return None }; + // The table is drawn in its own box; rows that grow past it don't move it. + let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh)); + let heights = table_drawn_heights(&st.doc, &st.selection, t); + let cells = deckcraft_render::table_cell_rects(t, &heights); + Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells }) +} + +impl TableFrame { + pub fn width(&self) -> f64 { + self.cols.iter().map(|w| w.max(0.0)).sum() + } + + pub fn height(&self) -> f64 { + self.heights.iter().map(|h| h.max(0.0)).sum() + } + + /// Table-local point for slide point `p`. + pub fn local(&self, p: Point) -> Point { + self.affine.inverse() * p + } + + /// Table-local rect of visible cell (`r`, `c`). + pub fn cell_rect(&self, r: usize, c: usize) -> Option { + self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2) + } + + /// The visible cell at table-local point `p` (points outside the table go to the nearest cell). + pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> { + let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0))); + let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1; + // Edges are shared: prefer the cell the point is strictly inside. + self.cells + .iter() + .find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1) + .or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r))) + .map(|(r, c, _)| (*r, *c)) + } + + /// The draggable border within `tol` of table-local point `p`: the inner borders and the right + /// and bottom edges (the left and top edges move the table). Borders hidden inside merged + /// cells don't count. + pub fn border_at(&self, p: Point, tol: f64) -> Option { + let (w, h) = (self.width(), self.height()); + if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) { + return None; + } + let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h)); + let mut best: Option<(f64, TableBorder)> = None; + let mut x = 0.0; + for (i, cw) in self.cols.iter().enumerate() { + x += cw.max(0.0); + let d = (p.x - x).abs(); + // A cell ending at this edge at this height shows the border. + let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Column(i))); + } + } + let mut y = 0.0; + for (i, rh) in self.heights.iter().enumerate() { + y += rh.max(0.0); + let d = (p.y - y).abs(); + let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Row(i))); + } + } + best.map(|b| b.1) + } +} + fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) @@ -78,7 +238,7 @@ fn at(s: &Session, p: &Value) -> (usize, usize) { } fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result { - let id = match id_param(p, "id") { + let id = match id_param(p, "id", cmd)? { Some(i) => i, None => { let st = s.doc()?; @@ -195,20 +355,31 @@ fn options(s: &mut Session, p: &Value) -> Result { }) } -fn cell_fill(s: &mut Session, p: &Value) -> Result { - let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) }; +/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the +/// caret, else every cell. +fn target_cells(s: &Session, p: &Value) -> Result Vec<(usize, usize)> + use<>> { let cells: Option> = p .get("cells") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); - let sel = s.doc()?.selection.cells; + .map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); + let st = s.doc()?; + let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c))); + Ok(move |t: &Table| match (&cells, sel) { + (Some(c), _) => c.clone(), + (None, Some((a, b))) => { + // The selection can name cells outside the table; only walk the ones that exist. + let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1))); + (a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect() + } + _ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(), + }) +} + +fn cell_fill(s: &mut Session, p: &Value) -> Result { + let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) }; + let targets = target_cells(s, p)?; with_table(s, p, "table.cellFill", move |t| { - let list: Vec<(usize, usize)> = match (&cells, sel) { - (Some(c), _) => c.clone(), - (None, Some((_, a, b))) => (a.0.min(b.0)..=a.0.max(b.0)).flat_map(|r| (a.1.min(b.1)..=a.1.max(b.1)).map(move |c| (r, c))).collect(), - _ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(), - }; - for (r, c) in list { + for (r, c) in targets(t) { if let Some(cell) = t.cell_mut(r, c) { cell.fill.clone_from(&fill); } @@ -217,6 +388,135 @@ fn cell_fill(s: &mut Session, p: &Value) -> Result { }) } +const LEFT: usize = 0; +const RIGHT: usize = 1; +const TOP: usize = 2; +const BOTTOM: usize = 3; + +fn borders(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::Line; + let which = str_param(p, "which").unwrap_or("all").to_string(); + const WHICH: [&str; 12] = ["all", "outside", "inside", "insideH", "insideV", "top", "bottom", "left", "right", "diagDown", "diagUp", "none"]; + if !WHICH.contains(&which.as_str()) { + return Err(bad("table.borders", format!("unknown `which` {which:?}; one of {}", WHICH.join(", ")))); + } + let color = color_param(p, "color").unwrap_or_else(|| deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))); + let width = f64_param(p, "width").filter(|w| w.is_finite()).unwrap_or(1.0).clamp(0.0, 100.0); + let mut line = Line::solid(color, width); + if let Some(d) = str_param(p, "dash").filter(|d| *d != "solid") { + if !deckcraft_model::style::Dash::PRESETS.contains(&d) { + return Err(bad("table.borders", format!("unknown dash {d:?}; one of {}", deckcraft_model::style::Dash::PRESETS.join(", ")))); + } + line.dash = Some(deckcraft_model::style::Dash::Preset { name: d.to_string() }); + } + // "none" clears to no line, so the table style's own lines don't show through either. + let line = if which == "none" { Line::none() } else { line }; + let targets = target_cells(s, p)?; + with_table(s, p, "table.borders", move |t| { + let list = targets(t); + let (Some(r0), Some(r1), Some(c0), Some(c1)) = + (list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max()) + else { + return Ok(()); + }; + let (rows, cols) = (t.n_rows(), t.n_cols()); + // Set one side of a cell and the facing side of its neighbour, as PowerPoint does. + let set = |t: &mut Table, r: usize, c: usize, side: usize| { + if let Some(cell) = t.cell_mut(r, c) { + cell.borders[side] = Some(line.clone()); + } + let n = match side { + LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)), + RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)), + TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)), + _ => (r + 1 < rows).then_some((r + 1, c, TOP)), + }; + if let Some((r, c, side)) = n + && let Some(cell) = t.cell_mut(r, c) + { + cell.borders[side] = Some(line.clone()); + } + }; + for (r, c) in list { + if r < r0 || r > r1 || c < c0 || c > c1 { + continue; + } + let outer = [c == c0, c == c1, r == r0, r == r1]; + for side in [LEFT, RIGHT, TOP, BOTTOM] { + let horizontal = side == TOP || side == BOTTOM; + let on = match which.as_str() { + "all" | "none" => true, + "outside" => outer[side], + "inside" => !outer[side], + "insideH" => horizontal && !outer[side], + "insideV" => !horizontal && !outer[side], + "top" => side == TOP && outer[side], + "bottom" => side == BOTTOM && outer[side], + "left" => side == LEFT && outer[side], + "right" => side == RIGHT && outer[side], + _ => false, + }; + if on { + set(t, r, c, side); + } + } + if let Some(cell) = t.cell_mut(r, c) { + match which.as_str() { + "diagDown" => cell.diag_down = Some(line.clone()), + "diagUp" => cell.diag_up = Some(line.clone()), + "none" => { + cell.diag_down = None; + cell.diag_up = None; + } + _ => {} + } + } + } + Ok(()) + }) +} + +fn cell_margins(s: &mut Session, p: &Value) -> Result { + // PowerPoint's presets (left/right, top/bottom). + let base = match str_param(p, "preset") { + Some("none") => [0.0; 4], + Some("narrow") => [3.6; 4], + Some("wide") => [14.4, 14.4, 14.4, 14.4], + Some("normal") | None => [7.2, 7.2, 3.6, 3.6], + Some(o) => return Err(bad("table.cellMargins", format!("unknown preset {o:?}; one of normal, none, narrow, wide"))), + }; + let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0)); + let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)]; + let targets = target_cells(s, p)?; + with_table(s, p, "table.cellMargins", move |t| { + for (r, c) in targets(t) { + if let Some(cell) = t.cell_mut(r, c) { + cell.margins = Some(m); + } + } + Ok(()) + }) +} + +fn cell_align(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::Anchor; + let a = match str_param(p, "anchor").unwrap_or("top") { + "middle" | "center" | "ctr" => Anchor::Middle, + "bottom" | "b" => Anchor::Bottom, + "top" | "t" => Anchor::Top, + o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))), + }; + let targets = target_cells(s, p)?; + with_table(s, p, "table.cellAlign", move |t| { + for (r, c) in targets(t) { + if let Some(cell) = t.cell_mut(r, c) { + cell.anchor = Some(a); + } + } + Ok(()) + }) +} + fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { with_table(s, p, "table.distribute", move |t| { if rows { @@ -233,9 +533,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { fn column_width(s: &mut Session, p: &Value) -> Result { let (_, c) = at(s, p); let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0); + let keep_total = bool_or(p, "keepTotal", false); with_table(s, p, "table.columnWidth", move |t| { - if let Some(x) = t.cols.get_mut(c) { - *x = w; + // Keeping the table's width, the column to the right gives or takes the difference (dragging + // an inner border). The last column has none: the table gets wider or narrower. + let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite()); + match pair { + Some(pair) if keep_total => { + let min = MIN_COLUMN_WIDTH.min(pair / 2.0); + let w = w.max(min).min(pair - min); + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + if let Some(x) = t.cols.get_mut(c + 1) { + *x = pair - w; + } + } + _ => { + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + } } Ok(()) }) @@ -253,7 +571,7 @@ fn row_height(s: &mut Session, p: &Value) -> Result { } fn select_cells(s: &mut Session, p: &Value) -> Result { - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "table.selectCells")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?, }; @@ -266,3 +584,42 @@ fn select_cells(s: &mut Session, p: &Value) -> Result { })?; ok() } + +fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> { + let a = p.get(key)?; + let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?); + (x.is_finite() && y.is_finite()).then_some((x, y)) +} + +/// The table under slide point `at` (frontmost), or the one named by `id`, with its origin. +pub(crate) fn table_at(s: &Session, p: &Value, at: (f64, f64)) -> Option<(deckcraft_model::ShapeId, (f64, f64))> { + let st = s.doc().ok()?; + // A malformed id names no table (never the one under the pointer instead). + let want = id_param(p, "id", "table.draw").ok()?; + st.shapes().iter().rev().find_map(|sh| { + let x = sh.xfrm?; + let ShapeKind::Table(t) = &sh.kind else { return None }; + let inside = at.0 >= x.x && at.1 >= x.y && at.0 <= x.x + t.width() && at.1 <= x.y + t.height(); + (want.map_or(inside, |w| w == sh.id)).then_some((sh.id, (x.x, x.y))) + }) +} + +fn draw_line(s: &mut Session, p: &Value) -> Result { + let from = point_param(p, "from").ok_or_else(|| bad("table.drawLine", "missing `from`"))?; + let to = point_param(p, "to").ok_or_else(|| bad("table.drawLine", "missing `to`"))?; + let (id, o) = table_at(s, p, from).ok_or_else(|| bad("table.drawLine", "no table there"))?; + let (a, b) = ((from.0 - o.0, from.1 - o.1), (to.0 - o.0, to.1 - o.1)); + with_table(s, &json!({"id": id.0}), "table.drawLine", move |t| { + if crate::drawtable::draw_line(t, a, b) { Ok(()) } else { Err(bad("table.drawLine", "the line doesn't divide any cell")) } + }) +} + +fn erase_line(s: &mut Session, p: &Value) -> Result { + let at = point_param(p, "at").ok_or_else(|| bad("table.eraseLine", "missing `at`"))?; + let tol = p.get("tol").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(6.0).clamp(0.5, 50.0); + let (id, o) = table_at(s, p, at).ok_or_else(|| bad("table.eraseLine", "no table there"))?; + let q = (at.0 - o.0, at.1 - o.1); + with_table(s, &json!({"id": id.0}), "table.eraseLine", move |t| { + if crate::drawtable::erase_at(t, q, tol) { Ok(()) } else { Err(bad("table.eraseLine", "no border to erase there")) } + }) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..9c6d88e1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2,10 +2,11 @@ use std::sync::Arc; +use deckcraft_geom::{Affine, Point, Rect}; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; -use deckcraft_model::{Presentation, ShapeId, ShapeKind}; +use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; @@ -13,10 +14,28 @@ use crate::{DocState, Result, Selection, Session, Target, TextSel}; pub fn specs() -> Vec { vec![ + cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used), + cmd!( + "font.replace", + "Replace Fonts", + ["Home", "Editing", "Replace"], + None, + "{from, to} — every explicit use of family `from`, theme fonts included → {replaced}", + has_doc, + replace_font + ), cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit), cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), + cmd!( + noundo "text.autocorrect", + "AutoCorrect Options", + ["Tools"], + None, + "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list", + always, + autocorrect_options + ), cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), - cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete), // The input method's composition: shown in place, an undo step only once committed. CommandSpec { id: "text.compose", @@ -38,7 +57,6 @@ pub fn specs() -> Vec { has_text, commit ), - cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret), cmd!( "text.delete", "Delete Text", @@ -95,12 +113,16 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { - let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?; + let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?; // Materialise an inherited placeholder box before editing (so autofit can change it). let r = s.edit(|doc, sel| { let mut t = t0.clone(); let body = body_mut(doc, sel, &t).ok_or_else(|| bad("text", "the text being edited is gone"))?; let r = f(body, &mut t); + // The saved shrink-on-overflow scale was for the old text: fit the new text afresh. + if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.body.autofit { + (*font_scale, *line_reduction) = (1.0, 0.0); + } sel.text = Some(t); Ok(r) })?; @@ -153,36 +175,67 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), } } /// Lay out the text being edited (for vertical caret movement and hit testing). pub fn layout_for(st: &DocState, t: &TextSel) -> Option { - if t.notes || t.cell.is_some() { + text_frame(st, t).map(|f| f.0) +} + +/// The text being edited as it is drawn: its layout, and the transform from layout space to the +/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the +/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. +pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { + if t.notes { return None; } let sh = st.shape(t.shape)?; - let body = sh.text.as_ref()?; let ctx = ctx_for(&st.doc, &st.selection)?; + let lay = |s: &Shape, body: &TextBody, rect: Rect| { + deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) + }; + if let Some((r, c)) = t.cell { + // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the + // table style's text look, exactly as the table draws it. + let placed = cell_on_slide(st, t.shape, r, c)?; + return Some((placed.layout, placed.to_slide)); + } + let body = sh.text.as_ref()?; let x = xfrm_of(&st.doc, &st.selection, sh); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - Some(deckcraft_text::layout( - &ctx, - sh, - body, - &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - )) + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let l = lay(sh, body, tr); + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + Some((l, m)) +} + +/// The text position under slide point `p` in the text being edited (its shape or cell). +pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { + match text_frame(st, t) { + Some((l, m)) => { + let h = l.hit(m.inverse() * p); + (h.para, h.ch) + } + None => (0, 0), + } } /// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and /// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is -/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and -/// table cells, which aren't laid out on the slide here. +/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes, +/// which are an egui text field. pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { - if t.notes || t.cell.is_some() { + if t.notes { return None; } + if let Some((row, col)) = t.cell { + let placed = cell_on_slide(st, t.shape, row, col)?; + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot))); + } let sh = st.shape(t.shape)?; let ctx = ctx_for(&st.doc, &st.selection)?; let x = xfrm_of(&st.doc, &st.selection, sh); @@ -204,6 +257,22 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) } +/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows +/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text +/// has no fill to take a colour from. +pub fn caret_color(st: &DocState, t: &TextSel) -> Option { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let body = sh.text.as_ref()?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let blank = Paragraph::default(); + let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); + let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); + Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) +} + fn pos_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) @@ -218,7 +287,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { })?; return ok(); } - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.edit")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, }; @@ -275,6 +344,7 @@ fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String { } pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) -> Result { + let inherited = inherited_aligns_for_edit(s); let text = { let st = s.doc()?; match &st.selection.text { @@ -292,12 +362,53 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } c })?; - autocorrect(s)?; + autocorrect(s, text.chars().count() == 1)?; Ok(json!({"caret": [pos.0, pos.1]})) } +/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an +/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` +/// when the shape can't be resolved (notes, a shape that is gone). +fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { + let mut out = [false; 9]; + let Ok(st) = s.doc() else { return out }; + let Some(sh) = st.shape(id) else { return out }; + let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; + for (lvl, set) in out.iter_mut().enumerate() { + let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; + *set = resolve::para(&ctx, sh, ¶).align.is_some(); + } + out +} + +/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). +fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { + match s.doc().ok().and_then(|st| st.selection.text.clone()) { + Some(t) if !t.notes => inherited_aligns(s, t.shape), + _ => [false; 9], + } +} + +/// A paragraph with no direction of its own whose first strong character is right-to-left +/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the +/// paragraph or what it inherits, `inherited`) sets its alignment. +fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); + if para.props.align.is_none() && !inherits_align { + para.props.align = Some(Align::Right); + } + } +} + /// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked /// run, the props to type with are the run's without the link. `None` elsewhere. fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { @@ -312,24 +423,148 @@ fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { Some(props) } -const AUTOCORRECT: &[(&str, &str)] = &[ +/// Built-in AutoCorrect replacements (common slips and symbols). +pub const AUTOCORRECT: &[(&str, &str)] = &[ ("teh", "the"), ("adn", "and"), ("recieve", "receive"), + ("recieved", "received"), ("seperate", "separate"), ("occured", "occurred"), + ("occurence", "occurrence"), ("thier", "their"), ("wich", "which"), + ("acheive", "achieve"), + ("accross", "across"), + ("adress", "address"), + ("alot", "a lot"), + ("appearence", "appearance"), + ("arguement", "argument"), + ("basicly", "basically"), + ("beggining", "beginning"), + ("beleive", "believe"), + ("calender", "calendar"), + ("commitee", "committee"), + ("definately", "definitely"), + ("dont", "don't"), + ("doesnt", "doesn't"), + ("didnt", "didn't"), + ("isnt", "isn't"), + ("embarass", "embarrass"), + ("enviroment", "environment"), + ("existance", "existence"), + ("foward", "forward"), + ("freind", "friend"), + ("goverment", "government"), + ("happend", "happened"), + ("i", "I"), + ("ive", "I've"), + ("immediatly", "immediately"), + ("independant", "independent"), + ("knowlege", "knowledge"), + ("libary", "library"), + ("neccessary", "necessary"), + ("necesary", "necessary"), + ("noticable", "noticeable"), + ("occassion", "occasion"), + ("posession", "possession"), + ("prefered", "preferred"), + ("publically", "publicly"), + ("realy", "really"), + ("recomend", "recommend"), + ("refered", "referred"), + ("relevent", "relevant"), + ("succesful", "successful"), + ("tommorow", "tomorrow"), + ("tomorow", "tomorrow"), + ("truely", "truly"), + ("untill", "until"), + ("wierd", "weird"), + ("youre", "you're"), ("(c)", "©"), ("(r)", "®"), ("(tm)", "™"), + ("(e)", "€"), + ("...", "…"), ("->", "→"), ("<-", "←"), + ("=>", "⇒"), ("--", "–"), ]; -/// After a space or punctuation, replace a just-typed autocorrect entry. -fn autocorrect(s: &mut Session) -> Result<()> { +const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + +/// The replacement for `word` from your entries, then the built-in list. +fn replacement(prefs: &crate::Prefs, word: &str) -> Option { + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { + return Some(to.clone()); + } + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { + return Some(to.clone()); + } + let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; + if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { + return None; + } + // "i" only on its own, lower case. + if *k == "i" && word != "i" { + return None; + } + Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) +} + +/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces +/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was +/// typed (the case rules apply only then). +pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { + let mut out = replacement(prefs, word); + if typing && out.is_none() { + let chars: Vec = word.chars().collect(); + let letters = chars.iter().all(|c| c.is_alphabetic()); + if prefs.ac_fractions { + out = match word { + "1/2" => Some("½".into()), + "1/4" => Some("¼".into()), + "3/4" => Some("¾".into()), + _ => None, + }; + } + if out.is_none() && letters && chars.len() >= 3 { + let upper = |c: &char| c.is_uppercase(); + let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); + let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); + if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { + out = + Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); + } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { + out = Some( + chars + .iter() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) + .collect(), + ); + } + } + if out.is_none() && prefs.ac_days && DAYS.contains(&word) { + out = Some(ed::change_case(word, "sentence")); + } + } + if typing && prefs.ac_sentence_caps && sentence_start { + let w = out.clone().unwrap_or_else(|| word.to_string()); + let mut cs = w.chars(); + if let Some(f) = cs.next() + && f.is_lowercase() + && w.chars().all(char::is_alphabetic) + { + out = Some(f.to_uppercase().chain(cs).collect()); + } + } + out.filter(|o| o != word) +} + +/// After a space or punctuation, correct the word just typed. +fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { if !s.prefs.autocorrect { return Ok(()); } @@ -341,14 +576,31 @@ fn autocorrect(s: &mut Session) -> Result<()> { if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { return Ok(()); } - let word_end = end - 1; + let mut word_end = end - 1; let mut start = word_end; while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { start -= 1; } - let word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); - let Some((_, rep)) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(&word)) else { return Ok(()) }; - let rep = if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(rep, "sentence") } else { rep.to_string() }; + // "word," and "word." also count: the punctuation was typed before the space. + let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); + if replacement(&s.prefs, &word).is_none() { + let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); + if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { + word_end -= word.chars().count() - trimmed.chars().count(); + word = trimmed.to_string(); + } + } + let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); + let sentence_start = before.is_none_or(|c| ".!?".contains(c)); + // "word..." becomes "word…", the word corrected too. + let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); + let rep = if ellipsis { + let stem = word.trim_end_matches('.'); + Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) + } else { + correct_word(&s.prefs, &word, sentence_start, typing) + }; + let Some(rep) = rep else { return Ok(()) }; let delta = rep.chars().count() as isize - word.chars().count() as isize; let st = s.doc_mut()?; let mut d = (*st.doc).clone(); @@ -367,13 +619,78 @@ fn autocorrect(s: &mut Session) -> Result<()> { Ok(()) } +fn autocorrect_options(s: &mut Session, p: &Value) -> Result { + let pr = &mut s.prefs; + for (key, slot) in [ + ("on", &mut pr.autocorrect), + ("twoInitials", &mut pr.ac_two_initials), + ("sentenceCaps", &mut pr.ac_sentence_caps), + ("days", &mut pr.ac_days), + ("capsLock", &mut pr.ac_caps_lock), + ("fractions", &mut pr.ac_fractions), + ] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + *slot = v; + } + } + if let Some(Value::Array(a)) = p.get("add") { + let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { + return Err(bad("text.autocorrect", "`add` is [from, to]")); + }; + let from = from.trim(); + if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { + return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); + } + pr.ac_entries.retain(|(k, _)| k != from); + pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); + if pr.ac_entries.len() >= 2000 { + return Err(bad("text.autocorrect", "the list is full")); + } + pr.ac_entries.push((from.to_string(), to.to_string())); + } + if let Some(from) = str_param(p, "remove") { + let had = pr.ac_entries.len(); + pr.ac_entries.retain(|(k, _)| k != from); + if had == pr.ac_entries.len() + && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) + && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) + { + pr.ac_removed.push(from.to_string()); + } + } + let list: Vec<(String, String)> = pr + .ac_entries + .iter() + .cloned() + .chain( + AUTOCORRECT + .iter() + .filter(|(k, _)| { + !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) + }) + .map(|(k, v)| (k.to_string(), v.to_string())), + ) + .collect(); + Ok(json!({ + "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, + "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, + "entries": list, + })) +} + pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + let inherited = inherited_aligns_for_edit(s); let pos = edit_text(s, |body, t| { let (a, b) = t.ordered(); let at = ed::delete(body, a, b); let c = ed::insert_body(body, at, src); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } c })?; Ok(json!({"caret": [pos.0, pos.1]})) @@ -482,21 +799,91 @@ fn commit(s: &mut Session, p: &Value) -> Result { Ok(json!({"caret": [caret.0, caret.1]})) } +/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s +/// text as laid out on the current slide: where spelling squiggles go. +pub fn range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + +/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. +pub fn cell_range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + row: usize, + col: usize, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; + let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { + return vec![]; + }; + let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() }; + let l = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + /// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, -/// thick)` per line, thick under the part being converted. Empty without a composition or when -/// the edited text isn't laid out on the slide (notes, table cells). +/// thick)` per line, thick under the part being converted. Empty without a composition, or for +/// notes (an egui text field draws its own composition). pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; + if t.notes { + return vec![]; + } + if let Some((row, col)) = t.cell { + let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] }; + return underline_segments(&placed.layout, placed.to_slide, c); + } let Some(sh) = st.shape(t.shape) else { return vec![] }; let Some(l) = layout_for(st, t) else { return vec![] }; let x = xfrm_of(&st.doc, &st.selection, sh); let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + underline_segments(&l, m, c) +} + +fn underline_segments( + layout: &deckcraft_text::TextLayout, + to_slide: deckcraft_geom::Affine, + c: &crate::Composition, +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; let mut out = vec![]; let mut add = |a: usize, b: usize, thick: bool| { - for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) { - out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick)); + for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) { + out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick)); } }; add(0, c.len, false); @@ -506,6 +893,35 @@ pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deck out } +/// A table cell's text laid out the way it is drawn, mapped into slide coordinates. +fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { + let sh = st.shape(id)?; + let ShapeKind::Table(tb) = &sh.kind else { return None }; + let cell = tb.cell(row, col)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let heights = super::table_drawn_heights(&st.doc, &st.selection, tb); + let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2; + let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() }; + let layout = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation); + Some(CellText { layout, to_slide }) +} + +struct CellText { + layout: deckcraft_text::TextLayout, + to_slide: deckcraft_geom::Affine, +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); insert_text(s, &text, None) @@ -687,13 +1103,28 @@ fn select_para(s: &mut Session, p: &Value) -> Result { ok() } +/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them. +/// Returns whether any tab was found. +pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool { + let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); + if tabs == 0 { + return false; + } + if let Some(r) = para.runs.first_mut() { + r.text = r.text.trim_start_matches('\t').to_string(); + } + para.level = tabs.min(8) as u8; + true +} + fn set(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.set")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, }; let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let inherited = inherited_aligns(s, id); edit_shapes(s, &json!({"id": id}), "text.set", |sh| { let target = match (&mut sh.kind, cell) { (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), @@ -706,16 +1137,13 @@ fn set(s: &mut Session, p: &Value) -> Result { let mut nb = TextBody::from_text(&text); for (i, para) in nb.paragraphs.iter_mut().enumerate() { // Leading tabs set the outline level. - let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); - if tabs > 0 - && let Some(r) = para.runs.first_mut() + if !apply_leading_tabs(para) + && let Some((lv, pp)) = levels.get(i) { - r.text = r.text.trim_start_matches('\t').to_string(); - para.level = tabs.min(8) as u8; - } else if let Some((lv, pp)) = levels.get(i) { para.level = *lv; para.props = pp.clone(); } + promote_rtl_if_needed(para, &inherited); for r in &mut para.runs { r.props = keep.clone(); } @@ -729,7 +1157,7 @@ fn set(s: &mut Session, p: &Value) -> Result { fn get(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.get")? { Some(i) => i, None => match &st.selection.text { Some(t) => t.shape, @@ -765,7 +1193,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { @@ -790,8 +1218,9 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in -/// (without its trailing space). Empty when the caret is at a word boundary. -fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { +/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink +/// edits this same span, so the dialog reads it too. +pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { let body = body_of(st, t).cloned().unwrap_or_default(); let (mut a, mut b) = t.ordered(); if a == b { @@ -854,7 +1283,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { @@ -900,3 +1329,95 @@ pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } ok() } + +fn fonts_used(s: &mut Session, _: &Value) -> Result { + let mut v = crate::fonts::used_fonts(&s.doc()?.doc); + v.sort_by_key(|f| f.to_lowercase()); + Ok(json!(v)) +} + +fn replace_font(s: &mut Session, p: &Value) -> Result { + let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); + let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); + if to.len() > 256 || to.chars().any(char::is_control) { + return Err(bad("font.replace", "`to` is not a font name")); + } + // Count on a copy first so a replace that finds nothing leaves no undo step. + let mut probe = (*s.doc()?.doc).clone(); + if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { + return Ok(json!({"replaced": 0})); + } + let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; + let st = s.doc_mut()?; + st.missing_fonts = crate::fonts::missing_fonts(&st.doc); + Ok(json!({"replaced": n})) +} + +#[cfg(test)] +mod rtl_promote_tests { + use deckcraft_model::text::{Align, Paragraph, Run}; + + use super::promote_rtl_if_needed; + + fn para(text: &str) -> Paragraph { + Paragraph { runs: vec![Run::new(text)], ..Default::default() } + } + + #[test] + fn right_aligns_only_when_no_alignment_is_set_or_inherited() { + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[true; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); + let mut p = para("Hello مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (None, None)); + } +} + +#[cfg(test)] +mod replace_font_tests { + use serde_json::json; + + use crate::Session; + + fn fonts(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() + } + + #[test] + fn replaces_runs_tables_notes_and_theme_and_undoes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); + let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); + assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); + let used = fonts(&mut s); + assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); + // The theme's body font is replaced too, so placeholders follow. + s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); + } + + #[test] + fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { + let mut s = Session::with_new(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs new file mode 100644 index 00000000..881a39d1 --- /dev/null +++ b/crates/engine/src/cmd/three_d.rs @@ -0,0 +1,432 @@ +//! Format Shape ▸ 3-D Format and 3-D Rotation: a shape's `a:scene3d` / `a:sp3d`, kept as XML on +//! the model (`Effects::raw3d`), read into fields, changed, and written back. + +use serde_json::Value; + +/// Camera presets PowerPoint offers (3-D Rotation ▸ Presets). +pub const CAMERAS: &[&str] = &[ + "orthographicFront", + "isometricTopUp", + "isometricTopDown", + "isometricBottomUp", + "isometricBottomDown", + "isometricLeftUp", + "isometricLeftDown", + "isometricRightUp", + "isometricRightDown", + "obliqueTopLeft", + "obliqueTopRight", + "obliqueBottomLeft", + "obliqueBottomRight", + "perspectiveFront", + "perspectiveLeft", + "perspectiveRight", + "perspectiveAbove", + "perspectiveBelow", + "perspectiveAboveLeftFacing", + "perspectiveAboveRightFacing", + "perspectiveContrastingLeftFacing", + "perspectiveContrastingRightFacing", + "perspectiveHeroicLeftFacing", + "perspectiveHeroicRightFacing", + "perspectiveHeroicExtremeLeftFacing", + "perspectiveHeroicExtremeRightFacing", + "perspectiveRelaxed", + "perspectiveRelaxedModerately", +]; + +/// Bevel presets (`ST_BevelPresetType`). +pub const BEVELS: &[&str] = + &["circle", "relaxedInset", "cross", "coolSlant", "angle", "softRound", "convex", "slope", "divot", "riblet", "hardEdge", "artDeco"]; + +/// Materials (`ST_PresetMaterialType`). +pub const MATERIALS: &[&str] = + &["warmMatte", "matte", "plastic", "metal", "dkEdge", "softEdge", "flat", "legacyWireframe", "powder", "translucentPowder", "clear", "softmetal"]; + +/// Light rigs (`ST_LightRigType`) PowerPoint offers. +pub const LIGHTS: &[&str] = &[ + "threePt", + "balanced", + "soft", + "harsh", + "flood", + "contrasting", + "morning", + "sunrise", + "sunset", + "chilly", + "freezing", + "flat", + "twoPt", + "glow", + "brightRoom", +]; + +/// Every camera preset the file format defines (`ST_PresetCameraType`); a file's camera is kept +/// only when it is one of these. +pub const ALL_CAMERAS: &[&str] = &[ + "legacyObliqueTopLeft", + "legacyObliqueTop", + "legacyObliqueTopRight", + "legacyObliqueLeft", + "legacyObliqueFront", + "legacyObliqueRight", + "legacyObliqueBottomLeft", + "legacyObliqueBottom", + "legacyObliqueBottomRight", + "legacyPerspectiveTopLeft", + "legacyPerspectiveTop", + "legacyPerspectiveTopRight", + "legacyPerspectiveLeft", + "legacyPerspectiveFront", + "legacyPerspectiveRight", + "legacyPerspectiveBottomLeft", + "legacyPerspectiveBottom", + "legacyPerspectiveBottomRight", + "orthographicFront", + "isometricTopUp", + "isometricTopDown", + "isometricBottomUp", + "isometricBottomDown", + "isometricLeftUp", + "isometricLeftDown", + "isometricRightUp", + "isometricRightDown", + "isometricOffAxis1Left", + "isometricOffAxis1Right", + "isometricOffAxis1Top", + "isometricOffAxis2Left", + "isometricOffAxis2Right", + "isometricOffAxis2Top", + "isometricOffAxis3Left", + "isometricOffAxis3Right", + "isometricOffAxis3Bottom", + "isometricOffAxis4Left", + "isometricOffAxis4Right", + "isometricOffAxis4Bottom", + "obliqueTopLeft", + "obliqueTop", + "obliqueTopRight", + "obliqueLeft", + "obliqueRight", + "obliqueBottomLeft", + "obliqueBottom", + "obliqueBottomRight", + "perspectiveFront", + "perspectiveLeft", + "perspectiveRight", + "perspectiveAbove", + "perspectiveBelow", + "perspectiveAboveLeftFacing", + "perspectiveAboveRightFacing", + "perspectiveContrastingLeftFacing", + "perspectiveContrastingRightFacing", + "perspectiveHeroicLeftFacing", + "perspectiveHeroicRightFacing", + "perspectiveHeroicExtremeLeftFacing", + "perspectiveHeroicExtremeRightFacing", + "perspectiveRelaxed", + "perspectiveRelaxedModerately", +]; + +/// Every light rig the file format defines (`ST_LightRigType`). +pub const ALL_LIGHTS: &[&str] = &[ + "legacyFlat1", + "legacyFlat2", + "legacyFlat3", + "legacyFlat4", + "legacyNormal1", + "legacyNormal2", + "legacyNormal3", + "legacyNormal4", + "legacyHarsh1", + "legacyHarsh2", + "legacyHarsh3", + "legacyHarsh4", + "threePt", + "balanced", + "soft", + "harsh", + "flood", + "contrasting", + "morning", + "sunrise", + "sunset", + "chilly", + "freezing", + "flat", + "twoPt", + "glow", + "brightRoom", +]; + +/// Light rig directions (`ST_LightRigDirection`). +pub const LIGHT_DIRS: &[&str] = &["tl", "t", "tr", "l", "r", "bl", "b", "br"]; + +/// Every material the file format defines (`ST_PresetMaterialType`). +pub const ALL_MATERIALS: &[&str] = &[ + "legacyMatte", + "legacyPlastic", + "legacyMetal", + "legacyWireframe", + "matte", + "plastic", + "metal", + "warmMatte", + "translucentPowder", + "powder", + "dkEdge", + "softEdge", + "clear", + "flat", + "softmetal", +]; + +/// One bevel: preset, width and height in points. +#[derive(Clone, Debug, PartialEq)] +pub struct Bevel { + pub preset: String, + pub w: f64, + pub h: f64, +} + +/// The 3-D settings of a shape. +#[derive(Clone, Debug, PartialEq)] +pub struct Shape3d { + pub camera: String, + /// Camera rotation (X, Y, Z in degrees, as PowerPoint shows them), when set. + pub rot: Option<(f64, f64, f64)>, + pub light: String, + pub light_dir: String, + pub top: Option, + pub bottom: Option, + /// Depth (extrusion), contour width and distance from ground, in points. + pub depth: f64, + pub contour: f64, + pub z: f64, + pub material: String, + /// `a:extrusionClr` / `a:contourClr` XML, kept as is. + pub colors: String, +} + +impl Default for Shape3d { + fn default() -> Self { + Shape3d { + camera: "orthographicFront".into(), + rot: None, + light: "threePt".into(), + light_dir: "t".into(), + top: None, + bottom: None, + depth: 0.0, + contour: 0.0, + z: 0.0, + material: "warmMatte".into(), + colors: String::new(), + } + } +} + +const EMU: f64 = 12700.0; +const ANGLE: f64 = 60000.0; +/// Largest length kept, in points (PowerPoint's own limit). +const MAX_PT: f64 = 1584.0; + +/// `value` when it is one of `known`, else `default`. Values from a file are written back into +/// XML as they are, so nothing outside these lists may pass. +fn known(value: Option<&str>, known: &[&str], default: &str) -> String { + value.filter(|v| known.contains(v)).unwrap_or(default).to_string() +} + +fn num(e: &deckcraft_pptx::XmlEl, name: &str) -> Option { + e.attr(name).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) +} + +impl Shape3d { + /// Reads kept 3-D XML (`a:scene3d` and `a:sp3d`); unknown or invalid parts take their defaults. + pub fn parse(raw: &str) -> Shape3d { + let mut s = Shape3d::default(); + let Ok(doc) = deckcraft_pptx::parse_xml(format!("{raw}").as_bytes()) else { return s }; + let root = &doc.root; + if let Some(cam) = root.path(&["scene3d", "camera"]) { + s.camera = known(cam.attr("prst"), ALL_CAMERAS, "orthographicFront"); + if let Some(rot) = cam.child("rot") { + let d = |n| num(rot, n).unwrap_or(0.0) / ANGLE; + s.rot = Some((d("lon"), d("lat"), d("rev"))); + } + } + if let Some(l) = root.path(&["scene3d", "lightRig"]) { + s.light = known(l.attr("rig"), ALL_LIGHTS, "threePt"); + s.light_dir = known(l.attr("dir"), LIGHT_DIRS, "t"); + } + if let Some(sp) = root.child("sp3d") { + let pt = |n, lo: f64| (num(sp, n).unwrap_or(0.0) / EMU).clamp(lo, MAX_PT); + s.depth = pt("extrusionH", 0.0); + s.contour = pt("contourW", 0.0); + s.z = pt("z", -MAX_PT); + s.material = known(sp.attr("prstMaterial"), ALL_MATERIALS, "warmMatte"); + for (name, slot) in [("bevelT", &mut s.top), ("bevelB", &mut s.bottom)] { + if let Some(b) = sp.child(name) { + let len = |n| (num(b, n).unwrap_or(76200.0) / EMU).clamp(0.0, MAX_PT); + *slot = Some(Bevel { preset: known(b.attr("prst"), BEVELS, "circle"), w: len("w"), h: len("h") }); + } + } + // Colours are re-serialized by the XML writer (escaped); only `a:`-prefixed ones, + // since they are written back inside our own `a:sp3d`. + s.colors = sp.elements().filter(|c| (c.is("extrusionClr") || c.is("contourClr")) && c.prefix() == "a").map(|c| c.to_xml()).collect(); + } + s + } + + /// Whether this is a flat shape with no 3-D at all. + pub fn is_flat(&self) -> bool { + self.camera == "orthographicFront" + && self.rot.is_none_or(|(x, y, z)| x == 0.0 && y == 0.0 && z == 0.0) + && self.top.is_none() + && self.bottom.is_none() + && self.depth == 0.0 + && self.contour == 0.0 + && self.z == 0.0 + } + + /// The `a:scene3d` and `a:sp3d` XML, or `None` for a flat shape. + pub fn to_xml(&self) -> Option { + if self.is_flat() { + return None; + } + let e = |pt: f64| (pt * EMU).round() as i64; + let mut x = format!(" { + let a = |d: f64| (d.rem_euclid(360.0) * ANGLE).round() as i64 % 21_600_000; + x += &format!(">", a(ry), a(rx), a(rz)); + } + None => x += "/>", + } + x += &format!(" 0.0 { + x += &format!(" extrusionH=\"{}\"", e(self.depth)); + } + if self.contour > 0.0 { + x += &format!(" contourW=\"{}\"", e(self.contour)); + } + if self.material != "warmMatte" { + x += &format!(" prstMaterial=\"{}\"", self.material); + } + x += ">"; + for (tag, b) in [("bevelT", &self.top), ("bevelB", &self.bottom)] { + if let Some(b) = b { + x += &format!("", e(b.w), e(b.h), b.preset); + } + } + x += &self.colors; + x += ""; + Some(x) + } + + /// Applies `{camera?, rotX?, rotY?, rotZ?, z?, bevelTop?, bevelTopW?, bevelTopH?, bevelBottom?, + /// bevelBottomW?, bevelBottomH?, depth?, contour?, material?, lighting?, lightAngle?}`. + pub fn apply(&mut self, p: &Value) { + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let pick = |k: &str, list: &[&str]| p.get(k).and_then(Value::as_str).filter(|v| list.contains(v)).map(str::to_string); + if let Some(c) = pick("camera", CAMERAS) { + self.camera = c; + // A preset sets its own angles. + self.rot = None; + } + let (rx, ry, rz) = (f("rotX"), f("rotY"), f("rotZ")); + if rx.is_some() || ry.is_some() || rz.is_some() { + let (ox, oy, oz) = self.rot.unwrap_or((0.0, 0.0, 0.0)); + let d = |v: Option, o: f64| v.map_or(o, |v| v.rem_euclid(360.0)); + self.rot = Some((d(rx, ox), d(ry, oy), d(rz, oz))); + } + if let Some(v) = f("z") { + self.z = v.clamp(-1584.0, 1584.0); + } + for (key, slot) in [("bevelTop", &mut self.top), ("bevelBottom", &mut self.bottom)] { + match p.get(key).and_then(Value::as_str) { + Some("none") => *slot = None, + Some(b) if BEVELS.contains(&b) => { + let old = slot.take().unwrap_or(Bevel { preset: String::new(), w: 6.0, h: 6.0 }); + *slot = Some(Bevel { preset: b.to_string(), ..old }); + } + _ => {} + } + if let Some(b) = slot.as_mut() { + if let Some(w) = f(&format!("{key}W")) { + b.w = w.clamp(0.0, 1584.0); + } + if let Some(h) = f(&format!("{key}H")) { + b.h = h.clamp(0.0, 1584.0); + } + } + } + if let Some(v) = f("depth") { + self.depth = v.clamp(0.0, 1584.0); + } + if let Some(v) = f("contour") { + self.contour = v.clamp(0.0, 1584.0); + } + if let Some(m) = pick("material", MATERIALS) { + self.material = m; + } + if let Some(l) = pick("lighting", LIGHTS) { + self.light = l; + } + } +} + +/// Sets a shape's 3-D settings on its effects, keeping `bevel` (the top bevel's preset) in step. +pub fn store(e: &mut deckcraft_model::Effects, s: &Shape3d) { + e.raw3d = s.to_xml(); + e.bevel = if e.raw3d.is_some() { s.top.as_ref().map(|b| b.preset.clone()) } else { None }; +} + +#[cfg(test)] +mod tests { + use super::*; + + const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + + /// Preset names from a file are written back into XML; anything outside the format's lists + /// (here, values that would close the tag and inject markup once unescaped) falls back to the + /// default instead. + #[test] + fn unknown_presets_from_a_file_are_not_written_back() { + let evil = "x"/><a:evil/>"; + let raw = format!( + r#""# + ); + let s = Shape3d::parse(&raw); + assert_eq!( + (s.camera.as_str(), s.light.as_str(), s.light_dir.as_str(), s.material.as_str()), + ("orthographicFront", "threePt", "t", "warmMatte") + ); + assert_eq!(s.top.as_ref().map(|b| b.preset.as_str()), Some("circle")); + assert_eq!(s.depth, 1.0); + let xml = s.to_xml().expect("not flat"); + assert!(!xml.contains("{xml}
").as_bytes()).expect("well-formed"); + assert_eq!(doc.root.path(&["sp3d", "extrusionClr", "srgbClr"]).and_then(|c| c.attr("val")), Some(r#"x"/>"#)); + } + + #[test] + fn known_presets_round_trip() { + let raw = format!( + r#""# + ); + let s = Shape3d::parse(&raw); + assert_eq!(s.camera, "legacyObliqueTopLeft"); + assert_eq!(s.rot, Some((10.0, 20.0, 0.0))); + assert_eq!((s.light.as_str(), s.light_dir.as_str(), s.material.as_str()), ("legacyHarsh2", "tl", "legacyMetal")); + assert_eq!((s.z, s.contour), (-1.0, 1.0)); + assert_eq!(s.bottom, Some(Bevel { preset: "artDeco".into(), w: 3.0, h: 1.0 })); + assert_eq!(Shape3d::parse(&s.to_xml().expect("3-D")), s); + // Not XML at all: defaults. + assert_eq!(Shape3d::parse(" Vec { has_slide, timing ), + cmd!( + "transition.sound", + "Sound", + ["Transitions", "Timing"], + None, + "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in", + has_slide, + sound + ), cmd!("transition.applyAll", "Apply To All", ["Transitions", "Timing"], None, "{}", has_slide, apply_all), cmd!(query "transition.list", "Transition Gallery", [], None, "{} → [{id, label, category, duration, options}]", always, list), ] @@ -60,7 +69,11 @@ fn set(s: &mut Session, p: &Value) -> Result { t.option = option.clone(); t.duration_ms = duration.unwrap_or(info.3); t.raw = None; - sl.transition = if info.0 == "none" && t.advance_after_ms.is_none() && t.advance_on_click { None } else { Some(t) }; + sl.transition = if info.0 == "none" && t.advance_after_ms.is_none() && t.advance_on_click && t.sound.is_none() && !t.stop_sound { + None + } else { + Some(t) + }; } } Ok(()) @@ -108,6 +121,82 @@ fn timing(s: &mut Session, p: &Value) -> Result { ok() } +/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it. +fn sound(s: &mut Session, p: &Value) -> Result { + let list = slide_indices(s, p)?; + let looping = bool_param(p, "loop"); + let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| { + s.edit(|doc, _| { + for &i in &list { + if let Some(sl) = doc.slides.get_mut(i) { + f(Arc::make_mut(sl).transition.get_or_insert_with(Transition::default)); + } + } + Ok(()) + }) + }; + if bool_param(p, "none") == Some(true) { + edit_all(s, &|t| { + t.sound = None; + t.stop_sound = false; + t.sound_loop = false; + })?; + return Ok(json!({"sound": null})); + } + if bool_param(p, "stopPrevious") == Some(true) { + edit_all(s, &|t| { + t.sound = None; + t.sound_loop = false; + t.stop_sound = true; + })?; + return Ok(json!({"sound": null, "stopPrevious": true})); + } + let wants_file = p.get("path").is_some() || p.get("data").is_some() || p.get("builtin").is_some(); + if !wants_file { + // Only Loop Until Next Sound changes. + let Some(l) = looping else { return Err(bad("transition.sound", "give `path`, `data`, `builtin`, `none`, `stopPrevious` or `loop`")) }; + edit_all(s, &|t| t.sound_loop = l && t.sound.is_some())?; + return Ok(json!({"loop": l})); + } + let (name, bytes) = match str_param(p, "builtin") { + Some(id) => { + let (id, label) = crate::sounds::BUILTIN.iter().find(|b| b.0.eq_ignore_ascii_case(id)).ok_or_else(|| { + let ids: Vec<&str> = crate::sounds::BUILTIN.iter().map(|b| b.0).collect(); + bad("transition.sound", format!("unknown built-in sound `{id}` (one of {})", ids.join(", "))) + })?; + let bytes = crate::sounds::builtin(id).ok_or_else(|| bad("transition.sound", "built-in sound failed"))?; + (format!("{label}.wav"), bytes) + } + None => super::insert::media_bytes(p, "transition.sound")?, + }; + let ct = super::insert::content_type(&name, &bytes); + let bytes = Arc::new(bytes); + let audible = deckcraft_media::probe(&bytes).is_ok_and(|i| i.audio.is_some()); + let bytes = Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); + if !audible && !ct.starts_with("audio/") { + return Err(bad("transition.sound", format!("{name} is not a sound file ({ct})"))); + } + let lp = looping.unwrap_or(false); + let media = s.edit(|doc, _| { + // The same built-in sound is embedded once. + let existing = doc.media.iter().find(|m| m.name == name && m.data.as_slice() == bytes.as_slice()).map(|m| m.id); + let media = match existing { + Some(m) => m, + None => doc.add_media(&name, ct, bytes).ok_or_else(|| bad("transition.sound", NO_ID_SPACE))?, + }; + for &i in &list { + if let Some(sl) = doc.slides.get_mut(i) { + let t = Arc::make_mut(sl).transition.get_or_insert_with(Transition::default); + t.sound = Some(media); + t.stop_sound = false; + t.sound_loop = lp; + } + } + Ok(media) + })?; + Ok(json!({"sound": name, "media": media.0, "playable": audible, "loop": lp})) +} + fn apply_all(s: &mut Session, _p: &Value) -> Result { s.edit(|doc, sel| { let t = doc.slides.get(sel.slide).and_then(|x| x.transition.clone()); diff --git a/crates/engine/src/cmd/zoom.rs b/crates/engine/src/cmd/zoom.rs new file mode 100644 index 00000000..039e1671 --- /dev/null +++ b/crates/engine/src/cmd/zoom.rs @@ -0,0 +1,192 @@ +//! Insert ▸ Zoom: Summary Zoom, Section Zoom and Slide Zoom. +//! +//! A zoom is a picture of the target slide that jumps to it in the slide show. With Return to +//! Zoom (on for summary and section zooms, as in PowerPoint) the show comes back to the zoom +//! slide after the target slide, or the target section's last slide. In .pptx a zoom is saved as +//! the linked picture PowerPoint itself writes as the fallback for older versions. + +use std::sync::Arc; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::PictureFill; +use deckcraft_model::text::{Action, Hyperlink, TextBody}; +use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "insert.zoom", + "Zoom", + ["Insert", "Links"], + None, + "{kind?: slide|section|summary (default slide); slides?: [index] (slide and summary zooms); sections?: [index] (section zoom); returnToZoom?: bool (default: on for section and summary, off for slide)}", + has_slide, + zoom + )] +} + +/// One zoom tile: the slide it shows and jumps to, and the slide after which the show returns. +struct Target { + slide: SlideId, + index: usize, + last: SlideId, +} + +/// `n` tiles of `aspect` (w / h) laid out in a centred grid inside `area`. +fn grid(n: usize, area: Xfrm, aspect: f64) -> Vec { + let cols = (n as f64).sqrt().ceil().max(1.0) as usize; + let rows = n.div_ceil(cols).max(1); + let gap = 12.0; + let cw = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let ch = (area.h - gap * (rows as f64 - 1.0)) / rows as f64; + let (w, h) = if cw / ch > aspect { (ch * aspect, ch) } else { (cw, cw / aspect) }; + // A single slide zoom is a quarter of the slide wide, as PowerPoint inserts it. + let (w, h) = if n == 1 { (w.min(area.w / 2.0), h.min(area.w / 2.0 / aspect)) } else { (w, h) }; + let total_w = cols.min(n) as f64 * w + gap * (cols.min(n) as f64 - 1.0); + let total_h = rows as f64 * h + gap * (rows as f64 - 1.0); + let (x0, y0) = (area.x + (area.w - total_w) / 2.0, area.y + (area.h - total_h) / 2.0); + (0..n).map(|k| Xfrm::new(x0 + (k % cols) as f64 * (w + gap), y0 + (k / cols) as f64 * (h + gap), w, h)).collect() +} + +fn indices(p: &Value, key: &str, n: usize, what: &str) -> Result> { + let Some(Value::Array(a)) = p.get(key) else { return Ok(vec![]) }; + let mut v = vec![]; + for x in a { + let i = x + .as_u64() + .map(|i| i as usize) + .filter(|i| *i < n) + .ok_or_else(|| bad("insert.zoom", format!("there is no {what} {x} (indices count from 0)")))?; + if !v.contains(&i) { + v.push(i); + } + } + Ok(v) +} + +fn zoom(s: &mut Session, p: &Value) -> Result { + let kind = str_param(p, "kind").unwrap_or("slide"); + let st = s.doc()?; + let doc = &st.doc; + let n = doc.slides.len(); + let targets: Vec = match kind { + "slide" => indices(p, "slides", n, "slide")? + .into_iter() + .filter_map(|i| doc.slides.get(i).map(|sl| Target { slide: sl.id, index: i, last: sl.id })) + .collect(), + "section" => { + let secs = indices(p, "sections", doc.sections.len(), "section")?; + secs.into_iter() + .filter_map(|k| { + let sec = doc.sections.get(k)?; + let first = *sec.slides.first()?; + let last = *sec.slides.last()?; + Some(Target { slide: first, index: doc.slide_index(first)?, last }) + }) + .collect() + } + "summary" => { + let mut starts = indices(p, "slides", n, "slide")?; + if starts.is_empty() { + // Default: the first slide of every section, or every slide's start when there are none. + starts = doc.sections.iter().filter_map(|sec| sec.slides.first().and_then(|id| doc.slide_index(*id))).collect(); + } + starts.sort_unstable(); + starts + .iter() + .enumerate() + .filter_map(|(k, &i)| { + // Each part runs to the slide before the next part (or the end). + let end = starts.get(k + 1).map(|j| j.saturating_sub(1)).unwrap_or(n.saturating_sub(1)).max(i); + Some(Target { slide: doc.slides.get(i)?.id, index: i, last: doc.slides.get(end)?.id }) + }) + .collect() + } + other => return Err(bad("insert.zoom", format!("unknown zoom kind `{other}` (slide, section or summary)"))), + }; + if targets.is_empty() { + return Err(bad("insert.zoom", "choose the slides (or sections) to zoom to")); + } + let back = bool_or(p, "returnToZoom", kind != "slide"); + let size = doc.slide_size; + // Pictures of the targets, about 480 px wide. + let scale = (480.0 / size.width.max(1.0)).clamp(0.05, 4.0); + let pictures: Vec<(Vec, String)> = targets + .iter() + .map(|t| { + let (png, _, _) = super::file::render_png(doc, t.index, scale, false); + let title = doc.slides.get(t.index).map(|sl| sl.title()).unwrap_or_default(); + let label = + if title.trim().is_empty() { format!("Slide {}", t.index + 1) } else { title.lines().next().unwrap_or("").trim().to_string() }; + (png, label) + }) + .collect(); + let summary = kind == "summary"; + let first_target = targets.iter().map(|t| t.index).min().unwrap_or(0); + s.edit(|doc, sel| { + let aspect = size.width / size.height.max(1.0); + let area = if summary { + Xfrm::new(size.width * 0.06, size.height * 0.26, size.width * 0.88, size.height * 0.66) + } else if targets.len() == 1 { + Xfrm::new(size.width * 0.5, size.height * 0.5, size.width * 0.45, size.height * 0.45) + } else { + Xfrm::new(size.width * 0.08, size.height * 0.12, size.width * 0.84, size.height * 0.8) + }; + let boxes = grid(targets.len(), area, aspect); + let mut shapes = vec![]; + for ((t, (png, label)), b) in targets.iter().zip(pictures).zip(boxes) { + let media = doc.add_media(&format!("zoom-{}.png", t.index + 1), "image/png", png).ok_or_else(|| bad("insert.zoom", NO_ID_SPACE))?; + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.zoom", NO_ID_SPACE))?); + shapes.push(Shape { + id, + name: format!( + "{} Zoom {}", + if summary { + "Summary" + } else if kind == "section" { + "Section" + } else { + "Slide" + }, + t.index + 1 + ), + descr: label.clone(), + xfrm: Some(b), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + click: Some(Hyperlink { + action: Action::Slide { slide: t.slide }, + tooltip: label, + highlight_click: false, + zoom_return: back.then_some(t.last), + sound: None, + stop_sound: false, + }), + ..Default::default() + }); + } + let ids: Vec = shapes.iter().map(|x| x.id).collect(); + if summary { + // A new Title Only slide before the first part, as PowerPoint makes it. + let layout = + defaults::layout_of_kind(doc, LayoutType::TitleOnly).or_else(|| doc.slides.get(first_target).map(|x| x.layout)).unwrap_or_default(); + let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("insert.zoom", NO_ID_SPACE))?; + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())) { + sh.text = Some(TextBody::from_text("Summary")); + } + slide.shapes.extend(shapes); + let at = first_target.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(slide)); + sel.slide = at; + sel.slides.clear(); + } else { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.zoom", "no slide"))?; + list.extend(shapes); + } + sel.shapes = ids.clone(); + sel.text = None; + Ok(json!({"kind": kind, "ids": ids, "returnToZoom": back, "slide": sel.slide})) + }) +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs new file mode 100644 index 00000000..93469f06 --- /dev/null +++ b/crates/engine/src/compare.rs @@ -0,0 +1,455 @@ +//! Review › Compare: the differences between the open presentation and a revised copy of it, slide +//! by slide, and accepting a revised slide into the open one. +//! +//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no +//! slide id at all, by position. Shapes match by id, then by name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId}; +use serde::Serialize; + +/// The revised presentation a document is being compared with. +#[derive(Clone, Debug)] +pub struct Compare { + pub name: String, + pub revised: Arc, + /// Slides match by position (the decks shared no slide id when the compare started); fixed + /// for the session so accepting slides doesn't switch the matching half way. + pub by_position: bool, +} + +/// Whether `cur` and `rev` share no slide id (then slides match by position). +pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool { + let ids: HashSet = rev.slides.iter().map(|s| s.id).collect(); + !cur.slides.iter().any(|s| ids.contains(&s.id)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// Only in the revised deck. + Inserted, + /// Only in the open deck. + Deleted, + /// In both, different. + Changed, +} + +/// One slide-level revision. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + /// Index in the open deck (for inserted: where it would go). + pub slide: usize, + /// Index in the revised deck (none for deleted). + pub revised: Option, + /// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`… + pub details: Vec, +} + +const MAX_DETAILS: usize = 50; + +/// Slide pairs (open index, revised index). +fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> { + if by_position { + return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect(); + } + let rev_ids: HashMap = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect(); + cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect() +} + +pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec { + let pairs = pairs(cur, rev, by_position); + let cur_matched: HashSet = pairs.iter().map(|p| p.0).collect(); + let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, j) in &pairs { + let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; + let details = slide_details(cur, a, rev, b); + if !details.is_empty() { + out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details }); + } + } + for (i, s) in cur.slides.iter().enumerate() { + if !cur_matched.contains(&i) { + out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] }); + } + } + for (j, s) in rev.slides.iter().enumerate() { + if !rev_matched.contains(&j) { + // After the open slide that matches the revised slide before it. + let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); + out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] }); + } + } + out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0))); + out +} + +fn slide_title(s: &Slide) -> String { + let t = s + .shapes + .iter() + .find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle))) + .and_then(|x| x.text.as_ref()) + .map(|b| b.text()) + .unwrap_or_default(); + let t = t.trim(); + if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() } +} + +fn shape_label(s: &Shape) -> String { + if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } +} + +fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { + p.media(id).map(|m| m.data.as_slice()) +} + +/// A shape without its ids (shape and media), for comparing across decks. +fn plain_shape(s: &Shape) -> Shape { + let mut c = s.clone(); + fn rec(shapes: &mut [Shape]) { + for s in shapes { + s.id = ShapeId(0); + if let Some(ch) = s.children_mut() { + rec(ch); + } + } + } + rec(std::slice::from_mut(&mut c)); + c +} + +fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec) { + let label = shape_label(b); + let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); + let mut what = vec![]; + if text(a) != text(b) { + what.push("text"); + } else if a.text != b.text { + what.push("text formatting"); + } + match (&a.xfrm, &b.xfrm) { + (Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"), + (Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"), + (x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"), + _ => {} + } + if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { + what.push("formatting"); + } + let pic = |p: &Presentation, s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + _ => None, + }; + if pic(cp, a) != pic(rp, b) { + what.push("picture"); + } + if what.is_empty() { + let (mut x, mut y) = (plain_shape(a), plain_shape(b)); + x.text = None; + y.text = None; + if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) { + x.kind = ShapeKind::Shape; + y.kind = ShapeKind::Shape; + } + if x != y { + what.push("properties"); + } + } + if !what.is_empty() { + out.push(format!("{label}: {}", what.join(", "))); + } +} + +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { + let mut out = vec![]; + let mut used: HashSet = HashSet::new(); + for sa in &a.shapes { + let k = b + .shapes + .iter() + .position(|x| x.id == sa.id) + .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + match k { + Some(k) if !used.contains(&k) => { + used.insert(k); + if let Some(sb) = b.shapes.get(k) { + shape_details(cp, sa, rp, sb, &mut out); + } + } + _ => out.push(format!("{} deleted", shape_label(sa))), + } + } + for (k, sb) in b.shapes.iter().enumerate() { + if !used.contains(&k) { + out.push(format!("{} inserted", shape_label(sb))); + } + } + let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::>(); + if out.is_empty() && order(a) != order(b) { + out.push("order of objects".into()); + } + let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone()); + if layout_name(cp, a) != layout_name(rp, b) { + out.push("layout".into()); + } + if a.notes.text() != b.notes.text() { + out.push("notes".into()); + } + if a.transition != b.transition { + out.push("transition".into()); + } + if a.animations.len() != b.animations.len() + || a.animations + .iter() + .zip(&b.animations) + .any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms)) + { + out.push("animations".into()); + } + if a.background != b.background { + out.push("background".into()); + } + if a.hidden != b.hidden { + out.push(if b.hidden { "hidden" } else { "shown" }.into()); + } + out.truncate(MAX_DETAILS); + out +} + +fn max_shape_id(shapes: &[Shape], m: &mut u32) { + deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0)); +} + +/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape +/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its +/// layout matched by id, then by name, then the first layout. An error when `doc` has used every +/// object id and cannot take the slide without duplicating ids (#407). +pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option) -> crate::Result { + let mut sl = slide.clone(); + // Media. + let mut mids = vec![]; + crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids); + let mut media_map: HashMap = HashMap::new(); + for id in mids { + let Some(m) = rev.media(id) else { continue }; + match doc.media(id) { + Some(x) if x.data == m.data => {} + None => doc.media.push(m.clone()), + Some(_) => { + let new = doc + .add_media(&m.name, &m.content_type, m.data.as_ref().clone()) + .ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?; + media_map.insert(id, new); + } + } + } + // Shape ids used elsewhere in the open deck. + let mut taken: HashSet = HashSet::new(); + for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + taken.insert(x.id.0); + }); + } + let mut id_map: HashMap = HashMap::new(); + fn rec( + doc: &mut Presentation, + shapes: &mut [Shape], + taken: &HashSet, + ids: &mut HashMap, + media: &HashMap, + depth: usize, + ) -> crate::Result<()> { + if depth > 64 { + return Ok(()); + } + for s in shapes { + if taken.contains(&s.id.0) { + let new = ShapeId(doc.alloc_id().ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?); + ids.insert(s.id, new); + s.id = new; + } + let remap = |m: &mut MediaId| { + if let Some(n) = media.get(m) { + *m = *n; + } + }; + match &mut s.kind { + ShapeKind::Picture { fill } => remap(&mut fill.media), + ShapeKind::Media(m) => { + remap(&mut m.media); + if let Some(p) = m.poster.as_mut() { + remap(p); + } + } + _ => {} + } + if let Some(Fill::Picture(pf)) = &mut s.fill { + remap(&mut pf.media); + } + if let Some(ch) = s.children_mut() { + rec(doc, ch, taken, ids, media, depth + 1)?; + } + } + Ok(()) + } + rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0)?; + for a in &mut sl.animations { + if let Some(n) = id_map.get(&a.shape) { + a.shape = *n; + } + if let Some(t) = a.trigger.as_mut() + && let Some(n) = id_map.get(t) + { + *t = *n; + } + } + let mut max = 0; + max_shape_id(&sl.shapes, &mut max); + doc.next_id = doc.next_id.max(max); + // Layout. + if doc.layout(sl.layout).is_none() { + let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone()); + let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id)); + sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default(); + } + match replacing { + Some(id) => sl.id = id, + None => { + if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 { + sl.id = SlideId(doc.alloc_id().ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?); + } + doc.next_id = doc.next_id.max(sl.id.0); + } + } + Ok(sl) +} + +/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show +/// (`None` when the change no longer applies), or an error when `doc` has used every object id. +pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> crate::Result> { + match change.kind { + ChangeKind::Changed => { + let Some(b) = change.revised.and_then(|r| rev.slides.get(r)).cloned() else { return Ok(None) }; + let Some(id) = doc.slides.get(change.slide).map(|s| s.id) else { return Ok(None) }; + let sl = adopt(doc, rev, &b, Some(id))?; + let Some(slot) = doc.slides.get_mut(change.slide) else { return Ok(None) }; + *slot = Arc::new(sl); + Ok(Some(change.slide)) + } + ChangeKind::Inserted => { + let Some(b) = change.revised.and_then(|r| rev.slides.get(r)).cloned() else { return Ok(None) }; + let sl = adopt(doc, rev, &b, None)?; + let at = change.slide.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(sl)); + Ok(Some(at)) + } + ChangeKind::Deleted => { + if change.slide >= doc.slides.len() || doc.slides.len() <= 1 { + return Ok(None); + } + doc.slides.remove(change.slide); + Ok(Some(change.slide.min(doc.slides.len().saturating_sub(1)))) + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title + /// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3. + fn decks() -> (Session, String, usize) { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + // Make the revision in a second document. + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0; + s.execute("edit.select", &json!({"ids": [first]})).unwrap(); + s.execute("text.edit", &json!({"id": first})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Revised title"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).ok(); + let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0; + s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok(); + s.execute("slide.go", &json!({"index": n - 2})).ok(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + (s, rev, n) + } + + fn kinds(v: &Value) -> Vec { + v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect() + } + + #[test] + fn lists_inserted_deleted_and_changed_slides() { + let (mut s, rev, n) = decks(); + let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let k = kinds(&v); + assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}"); + let ch = &v["changes"].as_array().unwrap()[0]; + assert_eq!(ch["slide"], 0); + assert!(ch["details"].to_string().contains("text"), "{ch}"); + let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap(); + assert_eq!(ins["details"][0], "Brand new"); + let at = ins["slide"].as_u64().unwrap() as usize; + assert_eq!(at, n, "after the open slide matching the revised one before it"); + assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); + } + + #[test] + fn accept_one_then_all_makes_the_decks_equal_and_undoes() { + let (mut s, rev, n) = decks(); + s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + s.execute("review.accept", &json!({"index": 0})).unwrap(); + let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + assert_eq!(after, before - 1); + assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title")))); + s.execute("review.accept", &json!({"all": true})).unwrap(); + let left = s.execute("review.revisions", &json!({})).unwrap(); + assert!(left["changes"].as_array().unwrap().is_empty(), "{left}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate()); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before); + s.execute("review.endCompare", &json!({})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"all": true})).is_err()); + } + + #[test] + fn unrelated_decks_match_by_position_and_bad_input_fails() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap(); + let mut other = Session::with_new(); + other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap(); + other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap(); + assert!(kinds(&v).contains(&"inserted".into()), "{v}"); + s.execute("review.accept", &json!({"all": true})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty()); + assert!(s.doc().unwrap().doc.validate().is_empty()); + assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err()); + assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err()); + assert!(s.execute("review.compare", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"index": 999})).is_err()); + } +} diff --git a/crates/engine/src/drawtable.rs b/crates/engine/src/drawtable.rs new file mode 100644 index 00000000..2b49b7ac --- /dev/null +++ b/crates/engine/src/drawtable.rs @@ -0,0 +1,276 @@ +//! Draw Table: the pencil splits cells along the line you draw, the table eraser merges the two +//! cells on either side of the border you click. Pure operations on a [`Table`] in its own +//! coordinates (points from its top-left corner), unit-tested without a session. +//! +//! Both work on the table's *anchors* (each visible cell with its row and column span), edit +//! that list, then lay the grid back out, so merges anywhere in the table survive. + +use deckcraft_model::table::{Cell, Table}; + +/// A line closer than this to an existing grid line reuses it rather than adding a sliver. +const SNAP: f64 = 4.0; +/// Cap on the grid so a runaway pencil can't build an enormous table. +const MAX_GRID: usize = 200; + +#[derive(Clone, Debug)] +struct Anchor { + r: usize, + c: usize, + rs: usize, + cs: usize, + cell: Cell, +} + +fn anchors(t: &Table) -> Vec { + let mut v = vec![]; + for (r, row) in t.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate() { + if !cell.is_covered() { + let rs = (cell.row_span.max(1) as usize).min(t.rows.len().saturating_sub(r)).max(1); + let cs = (cell.grid_span.max(1) as usize).min(t.cols.len().saturating_sub(c)).max(1); + v.push(Anchor { r, c, rs, cs, cell: cell.clone() }); + } + } + } + v +} + +/// Lay `anchors` out into `t`'s grid (its rows and columns already sized). +fn rebuild(t: &mut Table, anchors: Vec) { + let (nr, nc) = (t.rows.len(), t.cols.len()); + for row in &mut t.rows { + row.cells = (0..nc).map(|_| Cell::new("")).collect(); + } + for a in anchors { + for r in a.r..(a.r + a.rs).min(nr) { + for c in a.c..(a.c + a.cs).min(nc) { + if let Some(x) = t.cell_mut(r, c) { + x.h_merge = c > a.c; + x.v_merge = r > a.r; + } + } + } + if let Some(x) = t.cell_mut(a.r, a.c) { + *x = Cell { grid_span: a.cs as u32, row_span: a.rs as u32, h_merge: false, v_merge: false, ..a.cell }; + } + } + t.normalize(); +} + +/// Swap rows and columns, so the vertical case reuses the horizontal one. +fn transpose(t: &mut Table) { + let (nr, nc) = (t.rows.len(), t.cols.len()); + let heights: Vec = t.rows.iter().map(|r| r.height).collect(); + let mut a = anchors(t); + for x in &mut a { + std::mem::swap(&mut x.r, &mut x.c); + std::mem::swap(&mut x.rs, &mut x.cs); + let [l, r, tp, b] = std::mem::take(&mut x.cell.borders); + x.cell.borders = [tp, b, l, r]; + } + let widths = std::mem::take(&mut t.cols); + t.cols = heights; + t.rows = widths.into_iter().map(|h| deckcraft_model::table::Row { height: h, cells: vec![] }).collect(); + debug_assert_eq!((t.rows.len(), t.cols.len()), (nc, nr)); + rebuild(t, a); +} + +fn edges(sizes: impl Iterator) -> Vec { + let mut e = vec![0.0]; + let mut acc = 0.0; + for s in sizes { + acc += s; + e.push(acc); + } + e +} + +/// Draw a line from `a` to `b` (table points). Mostly-horizontal lines split rows, mostly-vertical +/// ones split columns; only the cells the line crosses are divided. Returns whether anything +/// changed. +pub fn draw_line(t: &mut Table, a: (f64, f64), b: (f64, f64)) -> bool { + if ![a.0, a.1, b.0, b.1].iter().all(|v| v.is_finite()) || t.rows.is_empty() || t.cols.is_empty() { + return false; + } + if (b.0 - a.0).abs() >= (b.1 - a.1).abs() { + split_rows(t, (a.1 + b.1) / 2.0, a.0.min(b.0), a.0.max(b.0)) + } else { + transpose(t); + let changed = split_rows(t, (a.0 + b.0) / 2.0, a.1.min(b.1), a.1.max(b.1)); + transpose(t); + changed + } +} + +/// A horizontal line at `y` from `x0` to `x1`. +fn split_rows(t: &mut Table, y: f64, x0: f64, x1: f64) -> bool { + let ys = edges(t.rows.iter().map(|r| r.height)); + let xs = edges(t.cols.iter().copied()); + let total = ys.last().copied().unwrap_or(0.0); + if y <= SNAP || y >= total - SNAP || x1 - x0 < SNAP { + return false; + } + let mut a = anchors(t); + // Boundary index b: an existing grid line near y, or a new one splitting row k. + let b = match ys.iter().position(|e| (e - y).abs() <= SNAP) { + Some(b) => b, + None => { + if t.rows.len() >= MAX_GRID { + return false; + } + let Some(k) = ys.windows(2).position(|w| w[0] < y && y < w[1]) else { return false }; + let top = ys[k]; + let (h0, h1) = (y - top, ys[k + 1] - y); + if let Some(r) = t.rows.get_mut(k) { + r.height = h0; + } + t.rows.insert(k + 1, deckcraft_model::table::Row { height: h1, cells: vec![] }); + for x in &mut a { + if x.r > k { + x.r += 1; + } else if x.r + x.rs > k { + x.rs += 1; + } + } + k + 1 + } + }; + let mut changed = false; + let mut out = Vec::with_capacity(a.len() + 4); + for x in a { + let (l, r) = (xs.get(x.c).copied().unwrap_or(0.0), xs.get(x.c + x.cs).copied().unwrap_or(0.0)); + // The line divides a cell it crosses for more than half of the cell's width. + let overlap = (r.min(x1) - l.max(x0)).max(0.0); + if x.r < b && b < x.r + x.rs && overlap * 2.0 > r - l { + let below = + Anchor { r: b, c: x.c, rs: x.r + x.rs - b, cs: x.cs, cell: Cell { text: deckcraft_model::TextBody::default(), ..x.cell.clone() } }; + out.push(Anchor { rs: b - x.r, ..x }); + out.push(below); + changed = true; + } else { + out.push(x); + } + } + rebuild(t, out); + changed +} + +/// Erase the border nearest `p` (within `tol` points): the two cells it separates merge, when +/// together they make a rectangle. Returns whether a border was erased. +pub fn erase_at(t: &mut Table, p: (f64, f64), tol: f64) -> bool { + if !(p.0.is_finite() && p.1.is_finite()) { + return false; + } + let ys = edges(t.rows.iter().map(|r| r.height)); + let xs = edges(t.cols.iter().copied()); + let a = anchors(t); + let rect = |x: &Anchor| (xs[x.c], ys[x.r], xs[x.c + x.cs], ys[x.r + x.rs]); + let mut best: Option<(f64, usize, usize)> = None; + for (i, u) in a.iter().enumerate() { + for (j, v) in a.iter().enumerate() { + let (ul, ut, ur, ub) = rect(u); + // v directly right of u with the same rows, or directly below with the same columns. + let d = if v.c == u.c + u.cs && v.r == u.r && v.rs == u.rs && p.1 > ut && p.1 < ub { + (p.0 - ur).abs() + } else if v.r == u.r + u.rs && v.c == u.c && v.cs == u.cs && p.0 > ul && p.0 < ur { + (p.1 - ub).abs() + } else { + continue; + }; + if d <= tol && best.is_none_or(|b| d < b.0) { + best = Some((d, i, j)); + } + } + } + let Some((_, i, j)) = best else { return false }; + let mut a = a; + let v = a[j].clone(); + let u = &mut a[i]; + if v.r == u.r { + u.cs += v.cs; + } else { + u.rs += v.rs; + } + let mut more = v.cell.text.paragraphs.clone(); + if !v.cell.text.is_empty() { + if u.cell.text.is_empty() { + u.cell.text.paragraphs = more; + } else { + u.cell.text.paragraphs.append(&mut more); + } + } + a.remove(j); + rebuild(t, a); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid(t: &Table) -> String { + t.rows + .iter() + .map(|r| { + r.cells + .iter() + .map(|c| { + if c.is_covered() { + "." + } else if c.grid_span > 1 || c.row_span > 1 { + "M" + } else { + "o" + } + }) + .collect::() + }) + .collect::>() + .join("/") + } + + #[test] + fn pencil_splits_only_the_cells_it_crosses() { + let mut t = Table::new(1, 1, 300.0, 100.0); + assert!(draw_line(&mut t, (0.0, 40.0), (300.0, 40.0)), "full-width line adds a row"); + assert_eq!((grid(&t), t.rows[0].height, t.rows[1].height), ("o/o".into(), 40.0, 60.0)); + assert!(draw_line(&mut t, (120.0, 40.0), (120.0, 100.0)), "a vertical line in the bottom row only"); + assert_eq!(grid(&t), "M./oo"); + assert_eq!(t.cols, vec![120.0, 180.0]); + assert!(draw_line(&mut t, (120.0, 0.0), (120.0, 40.0)), "on the existing grid line: splits the merged top cell"); + assert_eq!(grid(&t), "oo/oo"); + assert!(!draw_line(&mut t, (0.0, 1.0), (300.0, 1.0)), "on the outer border"); + assert!(!draw_line(&mut t, (f64::NAN, 1.0), (300.0, 1.0))); + assert_eq!(t.width(), 300.0); + assert_eq!(t.height(), 100.0); + } + + #[test] + fn eraser_merges_neighbours_and_keeps_text() { + let mut t = Table::new(2, 2, 200.0, 50.0); + t.cell_mut(0, 0).unwrap().text = Cell::new("A").text; + t.cell_mut(0, 1).unwrap().text = Cell::new("B").text; + assert!(erase_at(&mut t, (100.0, 25.0), 5.0), "the vertical border in row 0"); + assert_eq!(grid(&t), "M./oo"); + assert_eq!(t.cell(0, 0).unwrap().text.paragraphs.len(), 2); + assert!(!erase_at(&mut t, (50.0, 75.0), 5.0), "nowhere near a border"); + let mut t = Table::new(2, 2, 200.0, 50.0); + t.merge(0, 0, 0, 1); + assert!(!erase_at(&mut t, (50.0, 50.0), 3.0), "the merged top cell and one bottom cell aren't a rectangle"); + assert!(erase_at(&mut t, (100.0, 75.0), 3.0)); + assert!(erase_at(&mut t, (50.0, 50.0), 3.0)); + assert_eq!(grid(&t), "M./.."); + } + + #[test] + fn grid_is_capped() { + let mut t = Table::new(1, 1, 300.0, 4000.0); + let mut n = 0; + for i in 1..400 { + if draw_line(&mut t, (0.0, i as f64 * 9.9), (300.0, i as f64 * 9.9)) { + n += 1; + } + } + assert!(t.rows.len() <= MAX_GRID && n > 100, "{} rows", t.rows.len()); + } +} diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 00000000..bcda8492 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,483 @@ +//! The folders automation may read and write. +//! +//! `deckcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`deckcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &str) -> Result { + if path.is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {path}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.deckcraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.deckcraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.deckcraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.deckcraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.deckcraft"), "x").unwrap(); + assert!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.deckcraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.deckcraft", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.deckcraft", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.deckcraft", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.deckcraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.deckcraft"), &base.join("in/leak.deckcraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.deckcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.deckcraft"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.deckcraft")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.deckcraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, + /// `write_file`), which check the roots. Engine code that reaches for `std::fs` itself would + /// bypass them, so none may: every file under `src/` (commands, `lib.rs`, `guard.rs`...) + /// except `io.rs`, this module, `recovery.rs` (the recovery folder is set by the host + /// application, never by a command's path, and a headless session has none) and the test + /// files. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. + // `recovery` and `spell` touch only the app's own folders (AutoRecover files, the + // system and user spelling dictionaries), never a path a command names. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs" | "spell.rs") + || name.starts_with("tests") + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Test modules (which build their own fixtures) come last in each file. + let code = text.split("#[cfg(test)]").next().unwrap_or(""); + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file}}: {offenders:#?}"); + } +} diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index fcbd2feb..5d5449bb 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -6,6 +6,21 @@ use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Bullet, TextBody}; use deckcraft_model::{Presentation, Shape, ShapeKind}; +/// Load each embedded face into the font database, so text in that family draws with the file's +/// font on a machine that doesn't have it. A face already loaded (bundled or installed) stays; +/// the part bytes are kept either way. MicroType Express subsets have no TrueType or OpenType +/// font to load and are left for the round trip. +pub fn install_embedded_fonts(p: &Presentation) { + let db = FontDb::global(); + for font in &p.embedded_fonts { + for id in font.faces().into_iter().flatten() { + let Some(item) = p.media(id) else { continue }; + let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue }; + db.add_font(sfnt.to_vec()); + } + } +} + /// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included) /// that aren't installed or bundled, sorted, without duplicates (case-insensitive). pub fn missing_fonts(p: &Presentation) -> Vec { @@ -21,7 +36,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec { } /// The families the slides' text runs and bullets are drawn in. -fn used_fonts(p: &Presentation) -> Vec { +pub fn used_fonts(p: &Presentation) -> Vec { let mut out: Vec = Vec::new(); let mut add = |f: String| { let f = f.trim().to_string(); @@ -63,3 +78,91 @@ fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String) } } } + +/// Replace the font family `from` with `to` everywhere it is named explicitly: text runs (and +/// their east Asian, complex script and symbol faces), bullet fonts, paragraph end marks, list +/// styles of shapes, layouts and masters, the master text styles, the deck default style, speaker +/// notes, tables and the theme fonts. Names compare case-insensitively; theme references +/// (`+mn-lt`) are left alone. Returns how many names changed. +pub fn replace_font(p: &mut Presentation, from: &str, to: &str) -> usize { + use std::sync::Arc; + let from = from.trim(); + let to = to.trim(); + if from.is_empty() || to.is_empty() || from.starts_with('+') || from == to { + return 0; + } + let mut n = 0usize; + let mut fix = |f: &mut Option| { + if f.as_deref().is_some_and(|x| x.trim().eq_ignore_ascii_case(from)) { + *f = Some(to.to_string()); + n = n.saturating_add(1); + } + }; + replace_list(&mut p.default_text_style, &mut fix); + for slide in &mut p.slides { + let sl = Arc::make_mut(slide); + replace_shapes(&mut sl.shapes, &mut fix); + replace_body(&mut sl.notes, &mut fix); + } + let mut theme_hits = 0usize; + let masters = p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()); + for m in masters { + let m = Arc::make_mut(m); + replace_shapes(&mut m.shapes, &mut fix); + for l in [&mut m.title_style, &mut m.body_style, &mut m.other_style] { + replace_list(l, &mut fix); + } + for lay in &mut m.layouts { + replace_shapes(&mut lay.shapes, &mut fix); + } + for set in [&mut m.theme.fonts.major, &mut m.theme.fonts.minor] { + for face in [&mut set.latin, &mut set.ea, &mut set.cs].into_iter().chain(set.scripts.iter_mut().map(|s| &mut s.typeface)) { + if face.trim().eq_ignore_ascii_case(from) { + *face = to.to_string(); + theme_hits = theme_hits.saturating_add(1); + } + } + } + } + n.saturating_add(theme_hits) +} + +fn replace_shapes(shapes: &mut [Shape], fix: &mut dyn FnMut(&mut Option)) { + for s in shapes { + if let Some(body) = &mut s.text { + replace_body(body, fix); + } + if let ShapeKind::Table(t) = &mut s.kind { + for cell in t.rows.iter_mut().flat_map(|r| &mut r.cells) { + replace_body(&mut cell.text, fix); + } + } + if let Some(children) = s.children_mut() { + replace_shapes(children, fix); + } + } +} + +fn replace_body(body: &mut TextBody, fix: &mut dyn FnMut(&mut Option)) { + replace_list(&mut body.list_style, fix); + for para in &mut body.paragraphs { + fix(&mut para.props.bullet_font); + replace_run(&mut para.end_props, fix); + for run in &mut para.runs { + replace_run(&mut run.props, fix); + } + } +} + +fn replace_list(l: &mut deckcraft_model::text::ListStyle, fix: &mut dyn FnMut(&mut Option)) { + for lvl in l.levels.iter_mut().flatten() { + fix(&mut lvl.para.bullet_font); + replace_run(&mut lvl.run, fix); + } +} + +fn replace_run(r: &mut deckcraft_model::text::RunProps, fix: &mut dyn FnMut(&mut Option)) { + for f in [&mut r.font, &mut r.font_ea, &mut r.font_cs, &mut r.font_sym] { + fix(f); + } +} diff --git a/crates/engine/src/gif_export.rs b/crates/engine/src/gif_export.rs new file mode 100644 index 00000000..da4f044f --- /dev/null +++ b/crates/engine/src/gif_export.rs @@ -0,0 +1,421 @@ +//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`). +//! +//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame +//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant +//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and +//! build animations are not animated: a slide appears as it looks when its builds have finished. +//! +//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and +//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for +//! hours. + +use deckcraft_model::Presentation; +use serde_json::Value; + +use crate::cmd::bad; +use crate::{EngineError, Result}; + +const CMD: &str = "file.export"; + +/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080). +pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)]; +/// Frame width when none is given (the `medium` preset). +pub const DEFAULT_WIDTH: u32 = 960; +pub const MIN_WIDTH: u32 = 16; +pub const MAX_WIDTH: u32 = 3840; +/// Longest frame side: a very tall slide is scaled down to fit. +pub const MAX_SIDE: u32 = 3840; +pub const DEFAULT_SECONDS: f64 = 5.0; +/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway. +pub const MIN_SECONDS: f64 = 0.1; +pub const MAX_SECONDS: f64 = 600.0; +pub const MAX_FRAMES: usize = 500; +/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540). +pub const MAX_TOTAL_PIXELS: u64 = 200_000_000; +/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance. +const QUANTIZER_SPEED: i32 = 10; + +/// What to export. +#[derive(Clone, Debug, PartialEq)] +pub struct GifOptions { + /// Frame width in pixels; the height follows the slide's aspect ratio. + pub width: u32, + pub seconds_per_slide: f64, + /// Loop forever (`true`) or play once. + pub loop_forever: bool, + /// Slide indices in play order (default: all). + pub slides: Option>, + /// Hidden slides are left out unless this is set. + pub include_hidden: bool, +} + +impl Default for GifOptions { + fn default() -> Self { + GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false } + } +} + +/// An encoded GIF and what is in it. +#[derive(Clone, Debug)] +pub struct GifOutput { + pub bytes: Vec, + pub frames: usize, + pub width: u32, + pub height: u32, + /// Per-frame delay in hundredths of a second, as written. + pub delay_cs: u16, +} + +fn width_value(v: &Value) -> Result { + let err = + || bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge")); + match v { + Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err), + Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err), + _ => Err(err()), + } +} + +/// Check a per-slide duration (NaN, infinities and out-of-range values are errors). +pub fn check_seconds(s: f64) -> Result { + if (MIN_SECONDS..=MAX_SECONDS).contains(&s) { + Ok(s) + } else { + Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}"))) + } +} + +/// Present and not `null`. +fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> { + p.get(key).filter(|v| !v.is_null()) +} + +impl GifOptions { + /// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool | + /// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their + /// defaults; anything else that is out of range or of the wrong type is an error. + pub fn from_params(p: &Value) -> Result { + let mut o = GifOptions::default(); + if let Some(v) = given(p, "width") { + o.width = width_value(v)?; + } + if let Some(v) = given(p, "secondsPerSlide") { + let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?; + o.seconds_per_slide = check_seconds(s)?; + } + if let Some(v) = given(p, "loop") { + o.loop_forever = match v { + Value::Bool(b) => *b, + Value::String(s) if s.eq_ignore_ascii_case("forever") => true, + Value::String(s) if s.eq_ignore_ascii_case("once") => false, + _ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")), + }; + } + if let Some(v) = given(p, "slides") { + let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?; + if a.is_empty() { + return Err(bad(CMD, "`slides` is empty")); + } + if a.len() > MAX_FRAMES { + return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides"))); + } + let list = a + .iter() + .map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok())) + .collect::>>() + .ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?; + o.slides = Some(list); + } + if let Some(v) = given(p, "includeHidden") { + o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?; + } + Ok(o) + } + + /// The slides that become frames, in order: the chosen ones (or all), without hidden slides + /// unless `include_hidden`. + pub fn frames(&self, doc: &Presentation) -> Result> { + let n = doc.slides.len(); + let chosen: Vec = match &self.slides { + Some(list) => { + if let Some(i) = list.iter().find(|i| **i >= n) { + return Err(bad(CMD, format!("no slide {i} (the presentation has {n})"))); + } + list.clone() + } + None => (0..n).collect(), + }; + let frames: Vec = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect(); + if frames.is_empty() { + return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)")); + } + if frames.len() > MAX_FRAMES { + return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range"))); + } + Ok(frames) + } + + /// Delay per frame in hundredths of a second. + pub fn delay_cs(&self) -> Result { + let s = check_seconds(self.seconds_per_slide)?; + // In range by `check_seconds`: 10..=60_000. + Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16) + } +} + +/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and +/// no side exceeds [`MAX_SIDE`]. +pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) { + let (sw, sh) = (doc.slide_size.width, doc.slide_size.height); + let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 }; + let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH)); + let mut h = w * aspect; + let mut w2 = w; + if h > f64::from(MAX_SIDE) { + w2 = w * f64::from(MAX_SIDE) / h; + h = f64::from(MAX_SIDE); + } + let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 }; + (side(w2), side(h)) +} + +/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and +/// fully transparent pixels would otherwise become see-through). +fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec { + let len = (w as usize).saturating_mul(h as usize).saturating_mul(4); + if img.width != w || img.height != h || img.pixels.len() != len { + log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height); + return vec![255; len]; + } + let mut px = img.pixels.clone(); + for p in px.as_chunks_mut::<4>().0 { + let back = 255 - p[3]; + p[0] = p[0].saturating_add(back); + p[1] = p[1].saturating_add(back); + p[2] = p[2].saturating_add(back); + p[3] = 255; + } + px +} + +/// Render and encode the GIF. +pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result { + use image::codecs::gif::{GifEncoder, Repeat}; + + let frames = opts.frames(doc)?; + let delay_cs = opts.delay_cs()?; + let (w, h) = frame_size(doc, opts.width); + let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)); + if total > MAX_TOTAL_PIXELS { + return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len()))); + } + let scale = f64::from(w) / doc.slide_size.width; + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}")); + let mut bytes = Vec::new(); + { + let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED); + // No repeat block at all means "play once"; a repeat count of 0 means "forever". + if opts.loop_forever { + enc.set_repeat(Repeat::Infinite).map_err(enc_err)?; + } + for i in &frames { + let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() }); + let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h)) + .ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1); + enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?; + } + } + Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use image::AnimationDecoder; + use serde_json::json; + + use super::*; + use crate::Session; + + const COLOURS: [(&str, [u8; 3]); 4] = + [("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])]; + + /// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden. + fn deck() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"blank": true})).unwrap(); + while s.doc().unwrap().doc.slides.len() < COLOURS.len() { + s.execute("slide.new", &json!({})).unwrap(); + } + for (i, (hex, _)) in COLOURS.iter().enumerate() { + s.execute("design.background", &json!({"color": hex, "index": i})).unwrap(); + } + s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap(); + s + } + + fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec) { + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap(); + let count = dec.loop_count(); + (count, dec.into_frames().collect_frames().unwrap()) + } + + /// The NETSCAPE2.0 application block's loop count, if the GIF has one. + fn netscape_loops(bytes: &[u8]) -> Option { + let tag = b"NETSCAPE2.0"; + let at = bytes.windows(tag.len()).position(|w| w == tag)?; + // NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian). + let b = bytes.get(at + tag.len()..at + tag.len() + 4)?; + Some(u16::from_le_bytes([b[2], b[3]])) + } + + fn close(a: [u8; 4], b: [u8; 3]) -> bool { + (0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255 + } + + #[test] + fn exports_one_frame_per_visible_slide() { + let mut s = deck(); + let path = temp("deck.gif"); + let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap(); + assert_eq!(r["frames"], 3, "{r}"); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default"); + let (count, frames) = decode(&bytes); + assert!(matches!(count, image::metadata::LoopCount::Infinite)); + assert_eq!(frames.len(), 3, "the hidden slide is skipped"); + // Slides are 16:9: 320×180. + let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1]; + for (f, want) in frames.iter().zip(expected) { + assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180)); + assert_eq!(f.delay().numer_denom_ms(), (1500, 1)); + let px = f.buffer().get_pixel(160, 90).0; + assert!(close(px, want), "frame colour {px:?}, expected {want:?}"); + } + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180))); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn presets_ranges_hidden_slides_and_play_once() { + let s = deck(); + let doc = &s.doc().unwrap().doc; + let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap(); + let out = encode(doc, &o).unwrap(); + assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500)); + assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block"); + let (_, frames) = decode(&out.bytes); + let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1]; + for (f, w) in frames.iter().zip(want) { + assert!(close(f.buffer().get_pixel(320, 180).0, w)); + assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default"); + } + // A range that holds only the hidden slide has nothing to export. + let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap(); + assert!(encode(doc, &o).is_err()); + for (preset, w) in SIZE_PRESETS { + assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w); + } + assert_eq!( + GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(), + GifOptions { width: 1280, loop_forever: false, ..Default::default() } + ); + assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default()); + } + + #[test] + fn hostile_params_are_errors_not_panics() { + for p in [ + json!({"width": 0}), + json!({"width": 15}), + json!({"width": 3841}), + json!({"width": -5}), + json!({"width": 1e308}), + json!({"width": 640.5}), + json!({"width": "huge"}), + json!({"width": [640]}), + json!({"secondsPerSlide": 0}), + json!({"secondsPerSlide": 0.05}), + json!({"secondsPerSlide": -1}), + json!({"secondsPerSlide": 601}), + json!({"secondsPerSlide": 1e308}), + json!({"secondsPerSlide": "5"}), + json!({"loop": 1}), + json!({"loop": "twice"}), + json!({"slides": []}), + json!({"slides": [-1]}), + json!({"slides": [0.5]}), + json!({"slides": "1-3"}), + json!({"slides": vec![0; MAX_FRAMES + 1]}), + json!({"includeHidden": "yes"}), + ] { + assert!(GifOptions::from_params(&p).is_err(), "{p}"); + } + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] { + assert!(check_seconds(v).is_err(), "{v}"); + let o = GifOptions { seconds_per_slide: v, ..Default::default() }; + assert!(o.delay_cs().is_err(), "{v}"); + } + assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000); + assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10); + + // Through the command too: errors, the presentation untouched, no file written. + let mut s = deck(); + let path = temp("hostile.gif"); + for p in [ + json!({"width": 99999}), + json!({"secondsPerSlide": -3}), + json!({"slides": [0, 99]}), + json!({"slides": [18446744073709551615u64]}), + json!({"slides": [2]}), + ] { + let mut p = p; + p["path"] = json!(path.to_string_lossy()); + assert!(s.execute("file.export", &p).is_err(), "{p}"); + } + assert!(!path.exists()); + assert_eq!(s.doc().unwrap().doc.slides.len(), 4); + } + + #[test] + fn sizes_are_bounded() { + let mut s = deck(); + // A very tall slide is scaled down so no side exceeds MAX_SIDE. + s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let (w, h) = frame_size(doc, MAX_WIDTH); + assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}"); + // The total pixel budget stops huge requests before anything is rendered. + let mut big = Presentation::clone(doc); + big.slide_size = deckcraft_geom::Size::new(960.0, 540.0); + let slide = big.slides[0].clone(); + big.slides = vec![slide; 200]; + let o = GifOptions { width: MAX_WIDTH, ..Default::default() }; + let e = encode(&big, &o).unwrap_err().to_string(); + assert!(e.contains("too large"), "{e}"); + // A degenerate slide size falls back to 16:9 rather than dividing by zero. + let mut odd = Presentation::clone(doc); + odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN); + assert_eq!(frame_size(&odd, 640), (640, 360)); + } + + #[test] + fn gif_is_also_a_save_format_and_inferred_from_the_path() { + let s = deck(); + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif"); + let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap(); + assert_eq!(decode(&bytes).1.len(), 1); + } +} diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2a633ca6..87d1fa7c 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -52,6 +52,7 @@ impl Session { st.revision = revision.max(st.revision).saturating_add(1); } log::error!("command `{id}` panicked: {msg}"); + self.panics_caught = self.panics_caught.saturating_add(1); if let Some(dir) = self.recovery_dir.clone() && let Err(e) = crate::recovery::save(self, &dir) { @@ -85,7 +86,36 @@ mod tests { assert!(err.to_string().contains("test.panic") && err.to_string().contains("boom"), "{err}"); let st = s.active().unwrap(); assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "the document is the one from before the command"); + assert_eq!(s.panics_caught, 1, "the guard counts what it caught"); // The session keeps working. s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap(); } + + #[test] + fn a_command_that_fails_after_editing_leaves_the_document_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [236, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let before = s.active().unwrap().doc.clone(); + let undo_len = s.active().unwrap().history.undo.len(); + // `shape.connect` inserts the requested preset first, then rejects it because it is not a line. + let r = s.execute("shape.connect", &json!({"from": a, "to": b, "preset": "rect"})); + assert!(r.is_err()); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "the stray shape is gone"); + assert_eq!(st.history.undo.len(), undo_len); + } + + #[test] + fn a_failed_picture_change_adds_no_media() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [36, 36, 100, 100]})).unwrap(); + let before = s.active().unwrap().doc.clone(); + // `picture.change` stores the image first, then finds no shape with this id. + let r = s.execute("picture.change", &json!({"ids": [9999], "name": "a.png", "data": crate::cmd::base64_encode(b"not a picture")})); + assert!(r.is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before), "the stored image is gone"); + } } diff --git a/crates/engine/src/icon_data.rs b/crates/engine/src/icon_data.rs new file mode 100644 index 00000000..dc0f06d7 --- /dev/null +++ b/crates/engine/src/icon_data.rs @@ -0,0 +1,75 @@ +// Lucide 1.56.0 (commit 440a1e1dfb1e712505308da46bead3c28b1f1398). +// Stroke centerlines on a 24×24 viewBox. Independent SVG subpaths are separated by ` | `. +// ISC; Feather-derived icons MIT. See NOTICE and ATTRIBUTION.md. +static ICONS: &[Icon] = &[ + Icon { name: "arrow-up", label: "Arrow up", category: "Arrows", stroke: 2.0, d: "m5 12 7-7 7 7 | M12 19V5" }, + Icon { name: "arrow-down", label: "Arrow down", category: "Arrows", stroke: 2.0, d: "M12 5v14 | m19 12-7 7-7-7" }, + Icon { name: "arrow-left", label: "Arrow left", category: "Arrows", stroke: 2.0, d: "m12 19-7-7 7-7 | M19 12H5" }, + Icon { name: "arrow-right", label: "Arrow right", category: "Arrows", stroke: 2.0, d: "M5 12h14 | m12 5 7 7-7 7" }, + Icon { name: "chevron-right", label: "Chevron", category: "Arrows", stroke: 2.0, d: "m9 18 6-6-6-6" }, + Icon { name: "undo-2", label: "Undo", category: "Arrows", stroke: 2.0, d: "M9 14 4 9l5-5 | M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" }, + Icon { name: "refresh-cw", label: "Refresh", category: "Arrows", stroke: 2.0, d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8 | M21 3v5h-5 | M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16 | M8 16H3v5" }, + Icon { name: "move", label: "Move", category: "Arrows", stroke: 2.0, d: "M12 2v20 | m15 19-3 3-3-3 | m19 9 3 3-3 3 | M2 12h20 | m5 9-3 3 3 3 | m9 5 3-3 3 3" }, + Icon { name: "check", label: "Check", category: "Marks", stroke: 2.0, d: "M20 6 9 17l-5-5" }, + Icon { name: "x", label: "X", category: "Marks", stroke: 2.0, d: "M18 6 6 18 | m6 6 12 12" }, + Icon { name: "plus", label: "Plus", category: "Marks", stroke: 2.0, d: "M5 12h14 | M12 5v14" }, + Icon { name: "minus", label: "Minus", category: "Marks", stroke: 2.0, d: "M5 12h14" }, + Icon { name: "star", label: "Star", category: "Marks", stroke: 2.0, d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" }, + Icon { name: "heart", label: "Heart", category: "Marks", stroke: 2.0, d: "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" }, + Icon { name: "flag", label: "Flag", category: "Marks", stroke: 2.0, d: "M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528" }, + Icon { name: "bookmark", label: "Bookmark", category: "Marks", stroke: 2.0, d: "M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" }, + Icon { name: "award", label: "Award", category: "Marks", stroke: 2.0, d: "m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526 | M 6 8 A 6 6 0 0 1 12 2 A 6 6 0 0 1 18 8 A 6 6 0 0 1 12 14 A 6 6 0 0 1 6 8 Z" }, + Icon { name: "thumbs-up", label: "Thumbs up", category: "Marks", stroke: 2.0, d: "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z | M7 10v12" }, + Icon { name: "circle-check", label: "Check circle", category: "Marks", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | m16 9-5.5 5.5L8 12" }, + Icon { name: "triangle-alert", label: "Warning", category: "Marks", stroke: 2.0, d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3 | M12 9v4 | M12 17h.01" }, + Icon { name: "user", label: "User", category: "People", stroke: 2.0, d: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2 | M 8 7 A 4 4 0 0 1 12 3 A 4 4 0 0 1 16 7 A 4 4 0 0 1 12 11 A 4 4 0 0 1 8 7 Z" }, + Icon { name: "users", label: "Users", category: "People", stroke: 2.0, d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 | M16 3.128a4 4 0 0 1 0 7.744 | M22 21v-2a4 4 0 0 0-3-3.87 | M 5 7 A 4 4 0 0 1 9 3 A 4 4 0 0 1 13 7 A 4 4 0 0 1 9 11 A 4 4 0 0 1 5 7 Z" }, + Icon { name: "circle-user", label: "Account", category: "People", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M 9 10 A 3 3 0 0 1 12 7 A 3 3 0 0 1 15 10 A 3 3 0 0 1 12 13 A 3 3 0 0 1 9 10 Z | M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662" }, + Icon { name: "hand", label: "Hand", category: "People", stroke: 2.0, d: "M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2 | M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2 | M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8 | M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" }, + Icon { name: "calendar", label: "Calendar", category: "Time", stroke: 2.0, d: "M8 2v3 | M16 2v3 | M 5 3 H 19 A 2 2 0 0 1 21 5 V 19 A 2 2 0 0 1 19 21 H 5 A 2 2 0 0 1 3 19 V 5 A 2 2 0 0 1 5 3 Z | M3 9h18" }, + Icon { name: "clock", label: "Clock", category: "Time", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M12 6v6l4 2" }, + Icon { name: "hourglass", label: "Hourglass", category: "Time", stroke: 2.0, d: "M5 22h14 | M5 2h14 | M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22 | M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" }, + Icon { name: "timer", label: "Timer", category: "Time", stroke: 2.0, d: "M 10 2 L 14 2 | M 12 14 L 15 11 | M 4 14 A 8 8 0 0 1 12 6 A 8 8 0 0 1 20 14 A 8 8 0 0 1 12 22 A 8 8 0 0 1 4 14 Z" }, + Icon { name: "mail", label: "Mail", category: "Communication", stroke: 2.0, d: "m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7 | M 4 4 H 20 A 2 2 0 0 1 22 6 V 18 A 2 2 0 0 1 20 20 H 4 A 2 2 0 0 1 2 18 V 6 A 2 2 0 0 1 4 4 Z" }, + Icon { name: "phone", label: "Phone", category: "Communication", stroke: 2.0, d: "M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" }, + Icon { name: "message-circle", label: "Message", category: "Communication", stroke: 2.0, d: "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719" }, + Icon { name: "bell", label: "Bell", category: "Communication", stroke: 2.0, d: "M10.268 21a2 2 0 0 0 3.464 0 | M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" }, + Icon { name: "megaphone", label: "Megaphone", category: "Communication", stroke: 2.0, d: "M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z | M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14 | M8 6v8" }, + Icon { name: "at-sign", label: "Mention", category: "Communication", stroke: 2.0, d: "M 8 12 A 4 4 0 0 1 12 8 A 4 4 0 0 1 16 12 A 4 4 0 0 1 12 16 A 4 4 0 0 1 8 12 Z | M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" }, + Icon { name: "chart-column", label: "Column chart", category: "Charts", stroke: 2.0, d: "M3 3v16a2 2 0 0 0 2 2h16 | M18 17V9 | M13 17V5 | M8 17v-3" }, + Icon { name: "chart-bar", label: "Bar chart", category: "Charts", stroke: 2.0, d: "M3 3v16a2 2 0 0 0 2 2h16 | M7 16h8 | M7 11h12 | M7 6h3" }, + Icon { name: "chart-pie", label: "Pie chart", category: "Charts", stroke: 2.0, d: "M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z | M21.21 15.89A10 10 0 1 1 8 2.83" }, + Icon { name: "trending-up", label: "Trend", category: "Charts", stroke: 2.0, d: "M16 7h6v6 | m22 7-8.5 8.5-5-5L2 17" }, + Icon { name: "target", label: "Target", category: "Charts", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M 6 12 A 6 6 0 0 1 12 6 A 6 6 0 0 1 18 12 A 6 6 0 0 1 12 18 A 6 6 0 0 1 6 12 Z | M 10 12 A 2 2 0 0 1 12 10 A 2 2 0 0 1 14 12 A 2 2 0 0 1 12 14 A 2 2 0 0 1 10 12 Z" }, + Icon { name: "lightbulb", label: "Idea", category: "Charts", stroke: 2.0, d: "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5 | M9 18h6 | M10 22h4" }, + Icon { name: "presentation", label: "Presentation", category: "Charts", stroke: 2.0, d: "M2 3h20 | M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3 | m7 21 5-5 5 5" }, + Icon { name: "file", label: "File", category: "Files", stroke: 2.0, d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z | M14 2v5a1 1 0 0 0 1 1h5" }, + Icon { name: "folder", label: "Folder", category: "Files", stroke: 2.0, d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }, + Icon { name: "file-text", label: "Document", category: "Files", stroke: 2.0, d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z | M14 2v5a1 1 0 0 0 1 1h5 | M10 9H8 | M16 13H8 | M16 17H8" }, + Icon { name: "paperclip", label: "Attachment", category: "Files", stroke: 2.0, d: "m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551" }, + Icon { name: "clipboard", label: "Clipboard", category: "Files", stroke: 2.0, d: "M 9 2 H 15 A 1 1 0 0 1 16 3 V 5 A 1 1 0 0 1 15 6 H 9 A 1 1 0 0 1 8 5 V 3 A 1 1 0 0 1 9 2 Z | M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }, + Icon { name: "link", label: "Link", category: "Files", stroke: 2.0, d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71 | M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }, + Icon { name: "download", label: "Download", category: "Files", stroke: 2.0, d: "M12 15V3 | M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4 | m7 10 5 5 5-5" }, + Icon { name: "share-2", label: "Share", category: "Files", stroke: 2.0, d: "M 15 5 A 3 3 0 0 1 18 2 A 3 3 0 0 1 21 5 A 3 3 0 0 1 18 8 A 3 3 0 0 1 15 5 Z | M 3 12 A 3 3 0 0 1 6 9 A 3 3 0 0 1 9 12 A 3 3 0 0 1 6 15 A 3 3 0 0 1 3 12 Z | M 15 19 A 3 3 0 0 1 18 16 A 3 3 0 0 1 21 19 A 3 3 0 0 1 18 22 A 3 3 0 0 1 15 19 Z | M 8.59 13.51 L 15.42 17.49 | M 15.41 6.51 L 8.59 10.49" }, + Icon { name: "house", label: "Home", category: "Places", stroke: 2.0, d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8 | M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" }, + Icon { name: "map-pin", label: "Pin", category: "Places", stroke: 2.0, d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0 | M 9 10 A 3 3 0 0 1 12 7 A 3 3 0 0 1 15 10 A 3 3 0 0 1 12 13 A 3 3 0 0 1 9 10 Z" }, + Icon { name: "globe", label: "Globe", category: "Places", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20 | M2 12h20" }, + Icon { name: "building", label: "Building", category: "Places", stroke: 2.0, d: "M12 10h.01 | M12 14h.01 | M12 6h.01 | M16 10h.01 | M16 14h.01 | M16 6h.01 | M8 10h.01 | M8 14h.01 | M8 6h.01 | M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3 | M 6 2 H 18 A 2 2 0 0 1 20 4 V 20 A 2 2 0 0 1 18 22 H 6 A 2 2 0 0 1 4 20 V 4 A 2 2 0 0 1 6 2 Z" }, + Icon { name: "compass", label: "Compass", category: "Places", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" }, + Icon { name: "briefcase", label: "Briefcase", category: "Places", stroke: 2.0, d: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16 | M 4 6 H 20 A 2 2 0 0 1 22 8 V 18 A 2 2 0 0 1 20 20 H 4 A 2 2 0 0 1 2 18 V 8 A 2 2 0 0 1 4 6 Z" }, + Icon { name: "image", label: "Picture", category: "Media", stroke: 2.0, d: "M 5 3 H 19 A 2 2 0 0 1 21 5 V 19 A 2 2 0 0 1 19 21 H 5 A 2 2 0 0 1 3 19 V 5 A 2 2 0 0 1 5 3 Z | M 7 9 A 2 2 0 0 1 9 7 A 2 2 0 0 1 11 9 A 2 2 0 0 1 9 11 A 2 2 0 0 1 7 9 Z | m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }, + Icon { name: "camera", label: "Camera", category: "Media", stroke: 2.0, d: "M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z | M 9 13 A 3 3 0 0 1 12 10 A 3 3 0 0 1 15 13 A 3 3 0 0 1 12 16 A 3 3 0 0 1 9 13 Z" }, + Icon { name: "play", label: "Play", category: "Media", stroke: 2.0, d: "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" }, + Icon { name: "clapperboard", label: "Clapperboard", category: "Media", stroke: 2.0, d: "m12.296 3.464 3.02 3.956 | M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z | M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z | m6.18 5.276 3.1 3.899" }, + Icon { name: "music", label: "Music", category: "Media", stroke: 2.0, d: "M9 18V5l12-2v13 | M 3 18 A 3 3 0 0 1 6 15 A 3 3 0 0 1 9 18 A 3 3 0 0 1 6 21 A 3 3 0 0 1 3 18 Z | M 15 16 A 3 3 0 0 1 18 13 A 3 3 0 0 1 21 16 A 3 3 0 0 1 18 19 A 3 3 0 0 1 15 16 Z" }, + Icon { name: "settings", label: "Settings", category: "Objects", stroke: 2.0, d: "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915 | M 9 12 A 3 3 0 0 1 12 9 A 3 3 0 0 1 15 12 A 3 3 0 0 1 12 15 A 3 3 0 0 1 9 12 Z" }, + Icon { name: "search", label: "Search", category: "Objects", stroke: 2.0, d: "m21 21-4.34-4.34 | M 3 11 A 8 8 0 0 1 11 3 A 8 8 0 0 1 19 11 A 8 8 0 0 1 11 19 A 8 8 0 0 1 3 11 Z" }, + Icon { name: "info", label: "Info", category: "Objects", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M12 16v-4 | M12 8h.01" }, + Icon { name: "circle-alert", label: "Alert", category: "Objects", stroke: 2.0, d: "M 2 12 A 10 10 0 0 1 12 2 A 10 10 0 0 1 22 12 A 10 10 0 0 1 12 22 A 10 10 0 0 1 2 12 Z | M 12 8 L 12 12 | M 12 16 L 12.01 16" }, + Icon { name: "lock", label: "Lock", category: "Objects", stroke: 2.0, d: "M 5 11 H 19 A 2 2 0 0 1 21 13 V 20 A 2 2 0 0 1 19 22 H 5 A 2 2 0 0 1 3 20 V 13 A 2 2 0 0 1 5 11 Z | M7 11V7a5 5 0 0 1 10 0v4" }, + Icon { name: "key", label: "Key", category: "Objects", stroke: 2.0, d: "m2 21 9.6-9.6 | m7.5 15.5 2.3 2.3a1 1 0 0 1 0 1.4l-2.1 2.1a1 1 0 0 1-1.4 0L4 19 | M 10 7.5 A 5.5 5.5 0 0 1 15.5 2 A 5.5 5.5 0 0 1 21 7.5 A 5.5 5.5 0 0 1 15.5 13 A 5.5 5.5 0 0 1 10 7.5 Z" }, + Icon { name: "zap", label: "Bolt", category: "Objects", stroke: 2.0, d: "M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z" }, + Icon { name: "sun", label: "Sun", category: "Objects", stroke: 2.0, d: "M 8 12 A 4 4 0 0 1 12 8 A 4 4 0 0 1 16 12 A 4 4 0 0 1 12 16 A 4 4 0 0 1 8 12 Z | M12 2v2 | M12 20v2 | m4.93 4.93 1.41 1.41 | m17.66 17.66 1.41 1.41 | M2 12h2 | M20 12h2 | m6.34 17.66-1.41 1.41 | m19.07 4.93-1.41 1.41" }, + Icon { name: "moon", label: "Moon", category: "Objects", stroke: 2.0, d: "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" }, + Icon { name: "cloud", label: "Cloud", category: "Objects", stroke: 2.0, d: "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" }, +]; diff --git a/crates/engine/src/icons.rs b/crates/engine/src/icons.rs new file mode 100644 index 00000000..89c72180 --- /dev/null +++ b/crates/engine/src/icons.rs @@ -0,0 +1,230 @@ +//! Insert › Icons: Lucide stroke icons as custom geometry. +//! +//! The paths in `icon_data.rs` are Lucide 1.56.0 centerlines (24×24, fill none). They are converted +//! to DeckCraft's `M`/`L`/`C`/`Q`/`Z` syntax when inserted, so the shape is a stroked outline rather +//! than a filled silhouette or an SVG picture. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use deckcraft_geom::{BezPath, PathEl}; + +/// Design size of every icon, in path units. Lucide's viewBox is `0 0 24 24`. +pub const VIEW: f64 = 24.0; + +/// One icon from the Insert gallery. +pub struct Icon { + pub name: &'static str, + pub label: &'static str, + pub category: &'static str, + /// Design stroke width in viewBox units (Lucide's default is 2). + pub stroke: f64, + /// SVG path data. Independent subpaths (separate SVG elements) are separated by ` | `. + pub d: &'static str, +} + +include!("icon_data.rs"); + +/// Every icon, in gallery order. +pub fn icons() -> &'static [Icon] { + ICONS +} + +/// `name` is the Lucide id (`arrow-right`, `arrow_right`) or the gallery label (`Arrow right`). +pub fn find(name: &str) -> Option<&'static Icon> { + let raw = name.trim(); + if raw.is_empty() { + return None; + } + let key = raw.to_ascii_lowercase().replace('_', "-"); + ICONS.iter().find(|i| i.name == key || i.label.eq_ignore_ascii_case(raw)) +} + +/// The icon's outline in the 24×24 viewBox. +pub fn outline(name: &str) -> Option<&'static BezPath> { + let icon = find(name)?; + parsed().get(icon.name) +} + +/// DeckCraft path syntax for `name`, in the 24×24 viewBox. +pub fn path_data(name: &str) -> Option { + outline(name).and_then(deck_path) +} + +/// Stroke width in points for a shape `w`×`h` points. Lucide's stroke is `stroke` units on a +/// 24-unit box, so a 72 pt icon is a 6 pt line. Resizing the shape later does not change it. +pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 { + let span = w.min(h).abs().min(4_000.0); + let design = if icon.stroke.is_finite() && icon.stroke > 0.0 { icon.stroke } else { 2.0 }; + (span * design / VIEW).clamp(0.25, 400.0) +} + +fn parsed() -> &'static HashMap<&'static str, BezPath> { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + let mut m = HashMap::new(); + for icon in ICONS { + if let Some(bp) = bezpath(icon.d) { + m.insert(icon.name, bp); + } + } + m + }) +} + +/// Parse SVG path data, one independent subpath at a time (` | ` resets the current point). +fn bezpath(d: &str) -> Option { + let mut out = BezPath::new(); + for part in d.split(" | ") { + let part = part.trim(); + if part.is_empty() { + continue; + } + let bp = BezPath::from_svg(part).ok()?; + for el in bp.elements() { + out.push(*el); + } + } + if out.elements().is_empty() { None } else { Some(out) } +} + +fn deck_path(bp: &BezPath) -> Option { + let mut out = String::new(); + let mut started = false; + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) => { + push(&mut out, &format!("M {} {}", num(p.x), num(p.y))); + started = true; + } + PathEl::LineTo(p) if started => push(&mut out, &format!("L {} {}", num(p.x), num(p.y))), + PathEl::QuadTo(a, b) if started => { + push(&mut out, &format!("Q {} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y))); + } + PathEl::CurveTo(a, b, c) if started => { + push(&mut out, &format!("C {} {} {} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y), num(c.x), num(c.y))); + } + PathEl::ClosePath if started => out.push_str(" Z"), + _ => {} + } + } + if out.is_empty() { None } else { Some(out) } +} + +fn push(out: &mut String, cmd: &str) { + if !out.is_empty() { + out.push(' '); + } + out.push_str(cmd); +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s == "-0" { "0".into() } else { s } +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_geom::preset::FillMode; + use deckcraft_model::{Geom, ShapeId}; + use serde_json::json; + + use crate::Session; + + #[test] + fn every_icon_converts_and_a_second_subpath_keeps_its_own_origin() { + assert!(icons().len() >= 60); + for icon in icons() { + let bp = outline(icon.name).unwrap_or_else(|| panic!("{} did not parse", icon.name)); + assert!(bp.elements().len() >= 2, "{}", icon.name); + let d = path_data(icon.name).unwrap_or_else(|| panic!("{}", icon.name)); + assert!(d.starts_with('M'), "{d}"); + let again = deckcraft_render::parse_path(&d); + assert!(!again.elements().is_empty(), "{}", icon.name); + } + // `m19 12…` is a second SVG element, not a continuation of the shaft. + let down = path_data("arrow-down").unwrap(); + assert!(down.contains("M 19 12"), "{down}"); + // Relative arcs in the star become cubics our renderer understands. + assert!(path_data("star").unwrap().contains(" C "), "{}", path_data("star").unwrap()); + assert!(find("arrow_right").is_some_and(|i| i.name == "arrow-right")); + assert!(find("Star").is_some_and(|i| i.name == "star")); + assert!(find("nope").is_none()); + } + + #[test] + fn insert_icon_strokes_the_outline_and_round_trips_through_pptx() { + let mut s = Session::with_new(); + assert!(s.execute("insert.icon", &json!({})).is_err()); + assert!(s.execute("insert.icon", &json!({"name": "not-an-icon"})).is_err()); + let listed = s.execute("insert.icons", &json!({})).unwrap(); + assert!(listed.as_array().unwrap().iter().any(|i| i["name"] == "star" && i["category"] == "Marks")); + + let before = s.doc().unwrap().shapes().len(); + let id = s.execute("insert.icon", &json!({"name": "star", "rect": [100.0, 80.0, 72.0, 72.0]})).unwrap()["id"].as_u64().unwrap(); + { + let st = s.doc().unwrap(); + assert_eq!(st.shapes().len(), before + 1); + let sh = st.shape(ShapeId(id as u32)).unwrap(); + assert_eq!(sh.name, "Star"); + assert!(sh.fill.as_ref().is_some_and(|f| f.is_none())); + let Geom::Custom { paths } = &sh.geom else { panic!("{:?}", sh.geom) }; + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].fill, FillMode::None); + assert!(paths[0].stroke); + assert!((paths[0].w - VIEW).abs() < 1e-9 && (paths[0].h - VIEW).abs() < 1e-9); + assert!(paths[0].d.contains('M') && paths[0].d.contains('C')); + let width = sh.line.as_ref().and_then(|l| l.width).unwrap(); + assert!((width - 6.0).abs() < 1e-6, "{width}"); + let img = deckcraft_render::render_slide(&st.doc, st.selection.slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); + let ink = ink_in(&img, 100.0, 80.0, 72.0, 72.0, 2.0); + assert!(ink > 40, "star stroke pixels {ink}"); + let mid = img.pixel((100.0 + 36.0) as u32 * 2, (80.0 + 36.0) as u32 * 2); + let sum = mid[0] as u16 + mid[1] as u16 + mid[2] as u16; + assert!(sum > 680, "a stroked star is hollow: {mid:?}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), before); + + s.execute("insert.icon", &json!({"name": "arrow-right", "rect": [200.0, 200.0, 96.0, 96.0]})).unwrap(); + { + let st = s.doc().unwrap(); + let sh = st.shapes().iter().find(|s| s.name == "Arrow right").unwrap(); + let width = sh.line.as_ref().and_then(|l| l.width).unwrap(); + assert!((width - 8.0).abs() < 1e-6, "{width}"); + let img = deckcraft_render::render_slide(&st.doc, st.selection.slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); + assert!(ink_in(&img, 200.0, 200.0, 96.0, 96.0, 2.0) > 40); + } + + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let p = deckcraft_pptx::import(&bytes).unwrap(); + let sh = p.slides[0].shapes.iter().find(|s| s.name == "Arrow right").unwrap(); + let Geom::Custom { paths } = &sh.geom else { panic!("{:?}", sh.geom) }; + assert!(paths.iter().any(|path| path.fill == FillMode::None && path.stroke && path.d.contains('M'))); + let width = sh.line.as_ref().and_then(|l| l.width).unwrap(); + assert!((width - 8.0).abs() < 0.05, "{width}"); + } + + fn ink_in(img: &deckcraft_render::Image, x: f64, y: f64, w: f64, h: f64, scale: f64) -> usize { + let x0 = (x * scale).floor().max(0.0) as u32; + let y0 = (y * scale).floor().max(0.0) as u32; + let x1 = ((x + w) * scale).ceil().min(img.width as f64) as u32; + let y1 = ((y + h) * scale).ceil().min(img.height as f64) as u32; + let mut n = 0; + for yy in y0..y1 { + for xx in x0..x1 { + let p = img.pixel(xx, yy); + let sum = p[0] as u16 + p[1] as u16 + p[2] as u16; + if p[3] > 200 && sum < 680 { + n += 1; + } + } + } + n + } +} diff --git a/crates/engine/src/inkshape.rs b/crates/engine/src/inkshape.rs new file mode 100644 index 00000000..694440cf --- /dev/null +++ b/crates/engine/src/inkshape.rs @@ -0,0 +1,192 @@ +//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle. +//! +//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends +//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1, +//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the +//! centre. + +/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Recognized { + /// From `(x0, y0)` to `(x1, y1)`. + Line { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + }, + Rect { + x: f64, + y: f64, + w: f64, + h: f64, + }, + Ellipse { + x: f64, + y: f64, + w: f64, + h: f64, + }, + /// Apex at the top (`flip_v` false) or at the bottom. + Triangle { + x: f64, + y: f64, + w: f64, + h: f64, + flip_v: bool, + }, +} + +/// Strokes smaller than this (points) stay ink. +const MIN_SIZE: f64 = 6.0; +const MAX_POINTS: usize = 100_000; + +pub fn recognize(points: &[(f64, f64)]) -> Option { + let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect(); + if pts.len() < 2 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in &pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let (w, h) = (x1 - x0, y1 - y0); + let size = w.max(h); + if !size.is_finite() || size < MIN_SIZE { + return None; + } + let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum(); + let (first, last) = (pts[0], pts[pts.len() - 1]); + let gap = (last.0 - first.0).hypot(last.1 - first.1); + if length <= 0.0 { + return None; + } + // Open stroke: a line if nearly straight, otherwise not a shape. + if gap > 0.25 * size { + return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 }); + } + // Closed but flat: a line drawn back and forth isn't a shape. + if w.min(h) < 0.15 * size { + return None; + } + // Covered area (shoelace, the gap closed) over the bounding box. + let mut a2 = 0.0; + for k in 0..pts.len() { + let (p, q) = (pts[k], pts[(k + 1) % pts.len()]); + a2 += p.0 * q.1 - q.0 * p.1; + } + let cover = (a2.abs() / 2.0) / (w * h); + // Ellipse fit: distance from the centre in box-normalised units should stay near 1. + let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0); + let radial: Vec = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect(); + let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::() / radial.len() as f64; + if cover > 0.86 { + Some(Recognized::Rect { x: x0, y: y0, w, h }) + } else if cover > 0.66 && spread < 0.12 { + Some(Recognized::Ellipse { x: x0, y: y0, w, h }) + } else if (0.35..0.66).contains(&cover) { + // Apex: the side of the box with fewer points near it is where the triangle narrows. + let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count(); + let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count(); + Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> { + let mut v = vec![]; + for k in 0..corners.len() { + let (a, b) = (corners[k], corners[(k + 1) % corners.len()]); + for i in 0..per_side { + let t = i as f64 / per_side as f64; + // A little hand wobble. + let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8; + v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob)); + } + } + v.push((corners[0].0 + 2.0, corners[0].1 + 1.5)); + v + } + + #[test] + fn rectangles_ellipses_triangles_and_lines() { + let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap(); + assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}"); + let ell: Vec<(f64, f64)> = (0..=80) + .map(|i| { + let a = i as f64 / 80.0 * std::f64::consts::TAU; + (200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell)); + let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap(); + assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}"); + let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap(); + assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}"); + let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect(); + assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line)); + } + + #[test] + fn scribbles_dots_and_garbage_stay_ink() { + assert_eq!(recognize(&[]), None); + assert_eq!(recognize(&[(1.0, 1.0)]), None); + assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small"); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect(); + assert_eq!(recognize(&zig), None, "an open zigzag is no line"); + assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None); + let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect(); + assert_eq!(recognize(&there_and_back), None); + } + + #[test] + fn ink_to_shape_command_converts_and_undoes() { + use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind}; + use serde_json::json; + let mut s = crate::Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect(); + let mk = |v: &[(f64, f64)]| InkStroke { + points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(), + color: deckcraft_color::Rgba::rgb(200, 0, 0), + width: 3.0, + highlighter: false, + }; + let strokes = vec![mk(&rect), mk(&zig)]; + s.edit(|doc, sel| { + let id = ShapeId(doc.alloc_id().expect("id")); + crate::shapes_mut(doc, sel).unwrap().push(Shape { + id, + name: "Ink".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { strokes }, + ..Default::default() + }); + Ok(serde_json::Value::Null) + }) + .unwrap(); + let n0 = s.doc().unwrap().shapes().len(); + let r = s.execute("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["kept"], 1); + let sh = s.doc().unwrap().shapes(); + assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added"); + let made = sh.last().unwrap(); + assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect")); + assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), n0); + // Nothing recognisable: a no-op, not an error. Empty slide too. + s.execute("draw.inkToShape", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 00000000..c19efc0f --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,69 @@ +//! Reading and writing the files a command names by path. +//! +//! Every such read and write goes through [`read_file`] and [`write_file`], so the automation +//! roots ([`crate::file_access`]) are checked in one place. A command module that called +//! `std::fs` itself would bypass them; a test in `file_access` keeps that from happening. + +/// The bytes of the file at `path`, inside the automation read root when there is one. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file(path: &str) -> Result, String> { + crate::file_access::check_read(path)?; + std::fs::read(path).map_err(|e| format!("{path}: {e}")) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_file(path: &str) -> Result, String> { + Err(format!("{path}: files are opened through the browser on the web")) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +/// +/// The bytes go to a temporary sibling first and are renamed over `path`, so a failed save never +/// truncates the old file. The sibling is in the same folder, so it is inside the root whenever +/// `path` is. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + crate::file_access::check_write(path)?; + let err = |e: std::io::Error| format!("{path}: {e}"); + // A uniquely named temporary sibling, created exclusively (never following or reusing an + // existing file or symlink), is renamed over the destination: concurrent saves never share + // temporary bytes. + let mut attempts = 0; + let (tmp, mut file) = loop { + let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => break (tmp, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, + Err(e) => return Err(err(e)), + } + }; + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + let result = written.and_then(|()| std::fs::rename(&tmp, path)); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(err(e)); + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { + Err(format!("{path}: saving to a path is not available in the browser")) +} + +/// The size in bytes of the file at `path`; `None` when it can't be read (or is outside the +/// automation read root, so File › Info doesn't reveal files the agent may not read). +#[cfg(not(target_arch = "wasm32"))] +pub fn file_len(path: &str) -> Option { + crate::file_access::check_read(path).ok()?; + std::fs::metadata(path).ok().map(|m| m.len()) +} + +#[cfg(target_arch = "wasm32")] +pub fn file_len(_path: &str) -> Option { + None +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 665cc22e..38b5aa71 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -57,6 +57,11 @@ impl Session { if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } + if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { + self.tool.edit_points = None; + self.tool.edit_vertex = None; + return Ok(json!({"handled": true, "command": "shape.editPoints"})); + } if editing && let Some((id, params)) = self.text_key(k, mods) { let r = self.execute(id, ¶ms)?; return Ok(json!({"handled": true, "command": id, "result": r})); @@ -285,15 +290,22 @@ impl Session { let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) }; let Some((r, c)) = t.cell else { return Ok(Value::Null) }; let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) }; - let (nr, nc) = (tb.n_rows(), tb.n_cols()); + let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1)); let idx = r * nc + c; - if !back && idx + 1 >= nr * nc { - // Tab in the last cell adds a row. - self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?; - return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]})); - } - let ni = if back { idx.saturating_sub(1) } else { idx + 1 }; - self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?; + // Cells hidden under a merged cell are skipped. + let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered()); + let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) }; + let ni = match next { + Some(ni) => ni, + None if !back => { + // Tab in the last cell adds a row. + let last = nr.saturating_sub(1); + self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?; + return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]})); + } + None => idx, + }; + self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?; self.execute("text.selectAll", &json!({})) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..ac25d666 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,13 +8,25 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod compare; pub mod connect; +pub mod drawtable; +pub mod file_access; pub mod fonts; +pub mod gif_export; pub mod guard; +pub mod icons; +pub mod inkshape; +pub mod io; pub mod keys; pub mod links; +pub mod media_refs; pub mod recovery; pub mod sample; +pub mod sounds; +pub mod spell; +pub mod templates; +pub mod thesaurus; pub mod tools; use std::sync::Arc; @@ -27,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use deckcraft_geom as geom; pub use deckcraft_media as media; pub use deckcraft_model as model; +pub use deckcraft_pdf as pdf; pub use deckcraft_render as render; pub use tools::{Mods, PointerEvent, PointerKind, ToolKind}; @@ -100,6 +113,34 @@ pub enum Target { master: usize, layout: usize, }, + /// The handout master (View ▸ Handout Master), a page at the notes size. + Handout, + /// The notes master (View ▸ Notes Master), a page at the notes size. + NotesMaster, +} + +impl Target { + /// A notes-size master page (Handout or Notes Master view). + pub fn is_page_master(self) -> bool { + matches!(self, Target::Handout | Target::NotesMaster) + } +} + +/// The handout or notes master edited in that view. +pub fn page_master(doc: &Presentation, t: Target) -> Option<&Arc> { + match t { + Target::Handout => doc.handout_master.as_ref(), + Target::NotesMaster => doc.notes_master.as_ref(), + _ => None, + } +} + +pub fn page_master_mut(doc: &mut Presentation, t: Target) -> Option<&mut Arc> { + match t { + Target::Handout => doc.handout_master.as_mut(), + Target::NotesMaster => doc.notes_master.as_mut(), + _ => None, + } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -129,6 +170,12 @@ pub struct History { pub undo: Vec, pub redo: Vec, pub limit: usize, + /// Most memory (estimated bytes) the undo steps may keep beyond the current document; + /// 0 means [`UNDO_BYTE_BUDGET`]. + pub byte_limit: usize, + /// Estimated bytes each undo step keeps that the document after it doesn't share, by the + /// step's document address. + pub costs: std::collections::HashMap, } /// A live pointer interaction: the document before it began (one undo step at the end). @@ -173,9 +220,18 @@ pub struct DocState { pub missing_fonts: Vec, /// An input method's composition in progress ([`Composition`]). pub composition: Option, + /// Review › Compare in progress: the revised copy. + pub compare: Option, } -static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// A document id unique across processes, not just within this one: recovery entries are named +/// by it in a folder every running instance shares. Ids are this process's random base (see +/// [`recovery::instance_id`]) plus a counter. +fn next_uid() -> u64 { + recovery::instance_id().wrapping_add(NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) & recovery::UID_MASK +} impl DocState { pub fn new(doc: Presentation, path: Option, title: String) -> Self { @@ -188,10 +244,11 @@ impl DocState { path, revision: 1, interaction: None, - uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + uid: next_uid(), title, missing_fonts: Vec::new(), composition: None, + compare: None, } } pub fn is_dirty(&self) -> bool { @@ -212,6 +269,7 @@ impl DocState { match self.selection.target { Target::Slides => self.current_slide().map(|s| s.shapes.as_slice()).unwrap_or(&[]), Target::Master { master } => self.doc.masters.get(master).map(|m| m.shapes.as_slice()).unwrap_or(&[]), + Target::Handout | Target::NotesMaster => page_master(&self.doc, self.selection.target).map(|m| m.shapes.as_slice()).unwrap_or(&[]), Target::Layout { master, layout } => { self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[]) } @@ -225,11 +283,28 @@ impl DocState { } } +/// The page being edited: the slide size, or the notes size in Handout Master view. +pub fn page_size(st: &DocState) -> deckcraft_geom::Size { + if st.selection.target.is_page_master() { st.doc.notes_size } else { st.doc.slide_size } +} + +/// The handout or notes master as a one-master presentation at the notes page size, for drawing +/// it with the layout renderer. +pub fn master_page(doc: &Presentation, t: Target) -> Option { + let m = page_master(doc, t)?.clone(); + let mut p = doc.clone(); + p.slide_size = doc.notes_size; + p.masters = vec![m]; + p.slides.clear(); + Some(p) +} + /// Mutable access to the edited shape tree of a presentation. pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a mut Vec> { match sel.target { Target::Slides => doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).shapes), Target::Master { master } => doc.masters.get_mut(master).map(|m| &mut Arc::make_mut(m).shapes), + t @ (Target::Handout | Target::NotesMaster) => page_master_mut(doc, t).map(|m| &mut Arc::make_mut(m).shapes), Target::Layout { master, layout } => { doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)).map(|l| &mut l.shapes) } @@ -247,6 +322,18 @@ pub struct Prefs { pub duplicate_offset: f64, pub autocorrect: bool, pub smart_quotes: bool, + /// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first + /// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4 + /// and 3/4 with fraction characters. + pub ac_two_initials: bool, + pub ac_sentence_caps: bool, + pub ac_days: bool, + pub ac_caps_lock: bool, + pub ac_fractions: bool, + /// Your own "replace … with …" entries; they win over the built-in list. + pub ac_entries: Vec<(String, String)>, + /// Built-in entries you deleted. + pub ac_removed: Vec, pub author: String, pub undo_limit: usize, /// Minutes between AutoRecover saves of unsaved presentations. @@ -262,6 +349,13 @@ impl Default for Prefs { duplicate_offset: 12.0, autocorrect: true, smart_quotes: true, + ac_two_initials: true, + ac_sentence_caps: true, + ac_days: true, + ac_caps_lock: true, + ac_fractions: true, + ac_entries: Vec::new(), + ac_removed: Vec::new(), author: "Presenter".into(), undo_limit: 500, recovery_minutes: 1.0, @@ -269,6 +363,29 @@ impl Default for Prefs { } } +impl Prefs { + /// The preferences with every value in a range the editor can use: `prefs.json` is a file + /// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo + /// limit) must not break editing or exhaust memory. Out-of-range numbers take their + /// defaults or the nearest usable value. + pub fn sanitized(mut self) -> Prefs { + let d = Prefs::default(); + let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.grid_spacing = + if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing }; + self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset); + self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT); + self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes); + if self.author.chars().count() > 256 { + self.author = self.author.chars().take(256).collect(); + } + self + } +} + +/// The most undo steps [`Prefs::undo_limit`] can keep. +pub const MAX_UNDO_LIMIT: usize = 10_000; + /// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] @@ -319,6 +436,9 @@ pub struct Clipboard { pub media: Vec, pub text: Option, pub plain: String, + /// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special › + /// Keep Source Formatting. + pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>, /// How many times the shapes were pasted (each paste offsets further). pub pastes: u32, } @@ -351,6 +471,11 @@ pub struct Session { /// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the /// platform DeckCraft was built for. pub mac_keys: bool, + /// Command panics caught by the guard (the document was rolled back); soak tests require 0. + pub panics_caught: u64, + /// Presentations a host read off the UI thread, waiting for `file.adopt` (by token). + loaded: Vec<(u64, cmd::file::LoadedFile)>, + next_loaded: u64, } impl Default for Session { @@ -379,8 +504,28 @@ impl Session { media_status: Default::default(), gif_paused: Default::default(), mac_keys: cfg!(target_os = "macos"), + panics_caught: 0, + loaded: vec![], + next_loaded: 0, } } + /// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]); + /// `file.adopt` with the returned token adds it as a document, through the command path like + /// `file.open` (journaled, panic-guarded). Only the latest few wait; older ones are dropped. + pub fn stage_loaded(&mut self, f: cmd::file::LoadedFile) -> u64 { + const KEEP: usize = 4; + self.next_loaded = self.next_loaded.wrapping_add(1); + if self.loaded.len() >= KEEP { + self.loaded.remove(0); + } + self.loaded.push((self.next_loaded, f)); + self.next_loaded + } + /// The staged presentation for `token`, removed from the session. + pub(crate) fn take_loaded(&mut self, token: u64) -> Option { + let i = self.loaded.iter().position(|(t, _)| *t == token)?; + Some(self.loaded.remove(i).1) + } /// A session with one new presentation open. pub fn with_new() -> Self { let mut s = Self::new(); @@ -452,7 +597,23 @@ impl Session { self.depth += 1; let r = (spec.run)(self, params); self.depth -= 1; - let r = r?; + let r = match r { + Ok(r) => r, + Err(e) => { + // A command made of several edits can fail after some of them went through; the + // outermost one puts the presentation back, so a failed command changes nothing. + if outer + && let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut()) + && st.uid == uid + && !Arc::ptr_eq(&old, &st.doc) + { + st.doc = old; + st.selection = sel; + st.revision += 1; + } + return Err(e); + } + }; if let Some(st) = self.active_mut() { clamp_selection(st); } @@ -509,9 +670,15 @@ impl Session { if let Some(t) = st.selection.text.as_mut() { t.line_end = None; } + let owner = |sel: &Selection| sel.text.as_ref().map(|t| (sel.slide, sel.target, t.shape, t.cell, t.notes)); + let before = owner(&st.selection); f(&doc, &mut st.selection); clamp_selection(st); st.revision += 1; + // A composition belongs to the text body it began in: leaving that body settles it. + if st.composition.is_some() && owner(&st.selection) != before { + self.end_composition(); + } Ok(()) } } @@ -536,6 +703,7 @@ fn clamp_selection(st: &mut DocState) { st.selection.slides = slides; match st.selection.target { Target::Master { master } if master >= st.doc.masters.len() => st.selection.target = Target::Slides, + t @ (Target::Handout | Target::NotesMaster) if page_master(&st.doc, t).is_none() => st.selection.target = Target::Slides, Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => { st.selection.target = Target::Slides } @@ -543,14 +711,72 @@ fn clamp_selection(st: &mut DocState) { } } +/// Default for [`History::byte_limit`]. +pub const UNDO_BYTE_BUDGET: usize = 256 << 20; + +/// A rough size of a shape in memory: the struct, its text and kept XML, its children. +fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize { + let text: usize = + sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::()).sum()); + let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 }; + 768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids +} + +fn shapes_cost(shapes: &[deckcraft_model::Shape]) -> usize { + shapes.iter().map(|s| shape_cost(s, 0)).sum() +} + +/// Estimated bytes `doc` keeps alive that `newer` doesn't share: its slides, masters and media +/// that aren't the same allocations (undo steps share everything an edit didn't touch). +pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize { + use std::collections::HashSet; + let slides: HashSet<*const deckcraft_model::Slide> = newer.slides.iter().map(Arc::as_ptr).collect(); + let masters: HashSet<*const deckcraft_model::Master> = newer.masters.iter().map(Arc::as_ptr).collect(); + let media: HashSet<*const Vec> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect(); + let mut n = 64 * doc.slides.len(); + for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) { + n += 512 + shapes_cost(&s.shapes); + } + for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) { + n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::(); + } + for m in doc.media.iter().filter(|m| !media.contains(&Arc::as_ptr(&m.data))) { + n += m.data.len(); + } + n +} + pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) { - st.history.undo.push(e); - st.history.redo.clear(); - if st.history.undo.len() > st.history.limit.max(1) { - st.history.undo.remove(0); + let h = &mut st.history; + h.costs.insert(Arc::as_ptr(&e.doc) as usize, undo_cost(&e.doc, &st.doc)); + h.undo.push(e); + h.redo.clear(); + if h.undo.len() > h.limit.max(1) { + h.undo.remove(0); + } + // Memory too: drop the oldest steps while the kept ones pass the budget (the newest always + // stays, however big). A deck-wide edit keeps a copy of every slide, so 500 of them on a big + // deck could hold hundreds of copies. + let budget = if h.byte_limit == 0 { UNDO_BYTE_BUDGET } else { h.byte_limit }; + let cost = |h: &History, e: &HistoryEntry| h.costs.get(&(Arc::as_ptr(&e.doc) as usize)).copied().unwrap_or(0); + let mut total: usize = h.undo.iter().map(|e| cost(h, e)).sum(); + let mut drop = 0; + while total > budget && drop + 1 < h.undo.len() { + total -= cost(h, &h.undo[drop]); + drop += 1; + } + if drop > 0 { + h.undo.drain(..drop); + } + // Costs of steps that are gone (dropped, or redo steps cleared above). + if h.costs.len() > h.undo.len() + 16 { + let live: std::collections::HashSet = h.undo.iter().chain(h.redo.iter()).map(|e| Arc::as_ptr(&e.doc) as usize).collect(); + h.costs.retain(|k, _| live.contains(k)); } } +#[cfg(test)] +mod soak_tests; #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/engine/src/media_refs.rs b/crates/engine/src/media_refs.rs new file mode 100644 index 00000000..407bc3c3 --- /dev/null +++ b/crates/engine/src/media_refs.rs @@ -0,0 +1,411 @@ +//! Every [`MediaId`] the model can reference, found by type rather than by scanning JSON keys. +//! +//! One walker, written once ([`walkers!`]) and expanded twice: [`visit`] reads ids, [`visit_mut`] +//! rewrites them in place. It covers pictures (and their artistic-effect originals), picture fills +//! wherever a fill can sit (shapes, lines, text runs, table cells and styles, chart series and +//! points, backgrounds, theme format schemes), media clips and their posters, 3-D models, OLE and +//! SmartArt previews and kept parts, SmartArt node pictures, picture bullets, hyperlink, transition and +//! animation sounds, and embedded font faces. Adding a `MediaId` field to the model means adding +//! it here; the shape kinds are matched exhaustively so a new kind can't be missed. + +use std::collections::HashSet; + +use deckcraft_model::{MediaId, Presentation, Shape, Slide}; + +macro_rules! walkers { + ($($m:tt)?) => { + use deckcraft_model::chart::Chart; + use deckcraft_model::style::{Fill, Line, PictureFill}; + use deckcraft_model::table::{Table, TableBorder, TablePartStyle, TableStyleDef}; + use deckcraft_model::text::{Hyperlink, ListStyle, ParaProps, RunProps, TextBody, Bullet}; + use deckcraft_model::theme::Theme; + use deckcraft_model::{Background, EmbeddedFont, Layout, Master, MediaId, Shape, ShapeKind, Slide}; + + /// Deepest group nesting walked (the model's other walkers stop at 64 too). + const MAX_DEPTH: usize = 64; + + pub type F<'a> = dyn FnMut(&$($m)? MediaId) + 'a; + + pub fn picture(p: &$($m)? PictureFill, f: &mut F) { + f(&$($m)? p.media); + if let Some(a) = &$($m)? p.artistic { + f(&$($m)? a.original); + } + } + + pub fn fill(x: &$($m)? Fill, f: &mut F) { + if let Fill::Picture(p) = x { + picture(p, f); + } + } + + fn opt_fill(x: &$($m)? Option, f: &mut F) { + if let Some(x) = x { + fill(x, f); + } + } + + pub fn line(l: &$($m)? Line, f: &mut F) { + opt_fill(&$($m)? l.fill, f); + } + + fn opt_line(l: &$($m)? Option, f: &mut F) { + if let Some(l) = l { + line(l, f); + } + } + + fn link(h: &$($m)? Option, f: &mut F) { + if let Some(Hyperlink { sound: Some(id), .. }) = h { + f(id); + } + } + + fn run(r: &$($m)? RunProps, f: &mut F) { + opt_fill(&$($m)? r.fill, f); + opt_line(&$($m)? r.outline, f); + link(&$($m)? r.link, f); + link(&$($m)? r.hover, f); + } + + fn para(p: &$($m)? ParaProps, f: &mut F) { + if let Some(Bullet::Picture { media }) = &$($m)? p.bullet { + f(media); + } + } + + pub fn list(l: &$($m)? ListStyle, f: &mut F) { + for lv in (&$($m)? l.levels).into_iter().flatten() { + para(&$($m)? lv.para, f); + run(&$($m)? lv.run, f); + } + } + + pub fn text(t: &$($m)? TextBody, f: &mut F) { + list(&$($m)? t.list_style, f); + for p in &$($m)? t.paragraphs { + para(&$($m)? p.props, f); + run(&$($m)? p.def_props, f); + run(&$($m)? p.end_props, f); + for r in &$($m)? p.runs { + run(&$($m)? r.props, f); + } + } + } + + fn table(t: &$($m)? Table, f: &mut F) { + opt_fill(&$($m)? t.background, f); + for row in &$($m)? t.rows { + for c in &$($m)? row.cells { + text(&$($m)? c.text, f); + opt_fill(&$($m)? c.fill, f); + for b in &$($m)? c.borders { + opt_line(b, f); + } + opt_line(&$($m)? c.diag_down, f); + opt_line(&$($m)? c.diag_up, f); + } + } + } + + fn table_part(s: &$($m)? TablePartStyle, f: &mut F) { + opt_fill(&$($m)? s.fill, f); + for b in &$($m)? s.borders { + if let Some(TableBorder::Line { line: l }) = b { + line(l, f); + } + } + } + + pub fn table_style(t: &$($m)? TableStyleDef, f: &mut F) { + for (_, s) in &$($m)? t.parts { + table_part(s, f); + } + if let Some(s) = &$($m)? t.background { + table_part(s, f); + } + } + + fn chart(c: &$($m)? Chart, f: &mut F) { + for s in &$($m)? c.series { + opt_fill(&$($m)? s.fill, f); + opt_line(&$($m)? s.line, f); + for (_, x) in &$($m)? s.point_fills { + fill(x, f); + } + } + opt_fill(&$($m)? c.plot_fill, f); + opt_fill(&$($m)? c.chart_fill, f); + } + + fn shape_at(s: &$($m)? Shape, f: &mut F, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match &$($m)? s.kind { + ShapeKind::Shape | ShapeKind::Connector { .. } | ShapeKind::Ink { .. } => {} + ShapeKind::Picture { fill } => picture(fill, f), + ShapeKind::Group { children, .. } => { + for c in children { + shape_at(c, f, depth + 1); + } + } + ShapeKind::Table(t) => table(t, f), + ShapeKind::Chart(c) => chart(c, f), + ShapeKind::Media(m) => { + f(&$($m)? m.media); + if let Some(p) = &$($m)? m.poster { + f(p); + } + } + ShapeKind::Model3d { media, poster } => { + f(media); + if let Some(p) = poster { + f(p); + } + } + // Every field named, so a new one can't slip past. + ShapeKind::Opaque { xml: _, preview, label: _, parts } => { + if let Some(p) = preview { + f(p); + } + // SmartArt data, OLE embeddings… kept as media items (#209). + for (_, _, id) in parts { + f(id); + } + } + } + opt_fill(&$($m)? s.fill, f); + opt_line(&$($m)? s.line, f); + if let Some(t) = &$($m)? s.text { + text(t, f); + } + link(&$($m)? s.click, f); + link(&$($m)? s.hover, f); + if let Some(d) = &$($m)? s.diagram { + for n in &$($m)? d.nodes { + if let Some(p) = &$($m)? n.picture { + f(p); + } + } + } + } + + /// A shape and, for a group, everything in it. + pub fn shape(s: &$($m)? Shape, f: &mut F) { + shape_at(s, f, 0); + } + + pub fn shapes(v: &$($m)? [Shape], f: &mut F) { + for s in v { + shape_at(s, f, 0); + } + } + + fn background(b: &$($m)? Option, f: &mut F) { + if let Some(Background::Fill { fill: x }) = b { + fill(x, f); + } + } + + /// A slide: shapes, background, transition and animation sounds, notes. + pub fn slide(s: &$($m)? Slide, f: &mut F) { + shapes(&$($m)? s.shapes, f); + background(&$($m)? s.background, f); + if let Some(t) = &$($m)? s.transition + && let Some(id) = &$($m)? t.sound + { + f(id); + } + for a in &$($m)? s.animations { + if let Some(id) = &$($m)? a.sound { + f(id); + } + } + text(&$($m)? s.notes, f); + } + + pub fn layout(l: &$($m)? Layout, f: &mut F) { + shapes(&$($m)? l.shapes, f); + background(&$($m)? l.background, f); + } + + pub fn theme(t: &$($m)? Theme, f: &mut F) { + for x in &$($m)? t.format.fills { + fill(x, f); + } + for x in &$($m)? t.format.bg_fills { + fill(x, f); + } + for l in &$($m)? t.format.lines { + line(l, f); + } + } + + /// A master with its theme, text styles and layouts. + pub fn master(m: &$($m)? Master, f: &mut F) { + theme(&$($m)? m.theme, f); + shapes(&$($m)? m.shapes, f); + background(&$($m)? m.background, f); + list(&$($m)? m.title_style, f); + list(&$($m)? m.body_style, f); + list(&$($m)? m.other_style, f); + for l in &$($m)? m.layouts { + layout(l, f); + } + } + + pub fn font(e: &$($m)? EmbeddedFont, f: &mut F) { + for id in [&$($m)? e.regular, &$($m)? e.bold, &$($m)? e.italic, &$($m)? e.bold_italic].into_iter().flatten() { + f(id); + } + } + }; +} + +/// Read every media id under a part of the model. +pub mod visit { + walkers!(); +} + +/// Rewrite every media id under a part of the model in place. +pub mod visit_mut { + walkers!(mut); +} + +/// Every media reference in a presentation: slides, masters (with their layouts and themes), +/// the notes and handout masters, default text style, table styles and embedded fonts. +pub fn visit_presentation(p: &Presentation, f: &mut visit::F) { + for s in &p.slides { + visit::slide(s, f); + } + for m in p.masters.iter().chain(p.notes_master.iter()).chain(p.handout_master.iter()) { + visit::master(m, f); + } + visit::list(&p.default_text_style, f); + for t in &p.table_styles { + visit::table_style(t, f); + } + for e in &p.embedded_fonts { + visit::font(e, f); + } +} + +/// The media ids a presentation references, each once, in the order first met. +pub fn presentation_media(p: &Presentation) -> Vec { + collect(|f| visit_presentation(p, f)) +} + +/// The media ids a slide references (shapes, background, sounds, notes), each once. +pub fn slide_media(s: &Slide) -> Vec { + collect(|f| visit::slide(s, f)) +} + +/// The media ids shapes reference (groups included), each once. +pub fn shapes_media(v: &[Shape]) -> Vec { + collect(|f| visit::shapes(v, f)) +} + +fn collect(walk: impl FnOnce(&mut visit::F)) -> Vec { + let mut seen = HashSet::new(); + let mut out = vec![]; + walk(&mut |id: &MediaId| { + if seen.insert(*id) { + out.push(*id); + } + }); + out +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use deckcraft_model::style::{Artistic, Fill, PictureFill}; + use deckcraft_model::text::{Action, Bullet, Hyperlink}; + use deckcraft_model::{Background, MediaClip, ShapeKind, defaults}; + + use super::*; + + fn pf(media: u32) -> PictureFill { + PictureFill { media: MediaId(media), ..Default::default() } + } + + /// One reference of each kind, every one a different id. + fn deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = (*p.slides.first().cloned().unwrap_or_default()).clone(); + let pic = Shape { + kind: ShapeKind::Picture { + fill: PictureFill { artistic: Some(Artistic { effect: "artisticBlur".into(), original: MediaId(9002) }), ..pf(9001) }, + }, + ..Default::default() + }; + let clip = Shape { + kind: ShapeKind::Media(MediaClip { media: MediaId(9003), poster: Some(MediaId(9004)), ..Default::default() }), + ..Default::default() + }; + let mut text = deckcraft_model::text::TextBody::from_text("x"); + if let Some(para) = text.paragraphs.first_mut() { + para.props.bullet = Some(Bullet::Picture { media: MediaId(9007) }); + } + let filled = Shape { + fill: Some(Fill::Picture(pf(9005))), + click: Some(Hyperlink { + action: Action::NextSlide, + tooltip: String::new(), + highlight_click: false, + sound: Some(MediaId(9006)), + stop_sound: false, + zoom_return: None, + }), + text: Some(text), + ..Default::default() + }; + let group = Shape { kind: ShapeKind::Group { children: vec![pic, clip], child: Default::default() }, ..Default::default() }; + s.shapes = vec![group, filled]; + s.background = Some(Background::Fill { fill: Fill::Picture(pf(9008)) }); + p.slides = vec![Arc::new(s)]; + if let Some(m) = p.masters.first_mut() { + Arc::make_mut(m).theme.format.bg_fills.push(Fill::Picture(pf(9009))); + } + p.embedded_fonts.push(deckcraft_model::EmbeddedFont { regular: Some(MediaId(9010)), ..Default::default() }); + p + } + + #[test] + fn every_kind_of_reference_is_found() { + let p = deck(); + let mut got = presentation_media(&p); + got.sort(); + assert_eq!(got, (9001..=9010).map(MediaId).collect::>()); + let mut slide = p.slides.first().map(|s| slide_media(s)).unwrap_or_default(); + slide.sort(); + assert_eq!(slide, (9001..=9008).map(MediaId).collect::>()); + } + + #[test] + fn visit_mut_rewrites_every_reference() { + let p = deck(); + let mut s = (**p.slides.first().expect("slide")).clone(); + visit_mut::slide(&mut s, &mut |id: &mut MediaId| id.0 += 100); + let mut got = slide_media(&s); + got.sort(); + assert_eq!(got, (9101..=9108).map(MediaId).collect::>()); + } + + /// SmartArt and OLE objects keep their parts as media items (#209); those count as used. + #[test] + fn kept_parts_of_unmodelled_objects_are_references() { + let opaque = Shape { + kind: ShapeKind::Opaque { + xml: String::new(), + preview: Some(MediaId(9101)), + label: "object".into(), + parts: vec![("rId2".into(), "oleObject".into(), MediaId(9102))], + }, + ..Default::default() + }; + assert_eq!(shapes_media(std::slice::from_ref(&opaque)), vec![MediaId(9101), MediaId(9102)]); + } +} diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 30daa81e..707d0aad 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -5,9 +5,18 @@ //! they were saved. //! //! An entry is `.deckcraft` (the presentation) plus `.json` (`{"path", "title", -//! "saved"}`: the original file, the title and when it was written, Unix seconds). +//! "saved", "owner"}`: the original file, the title, when it was written (Unix seconds) and the +//! instance that owns it). +//! +//! Several instances share the folder. Document uids are unique across processes, each instance +//! holds a lock on `owner-.lock` while it runs, and `file.recovery.open` leaves alone +//! the entries of an instance whose lock is still held. Opening an entry doesn't delete it: the +//! recovered presentation keeps the entry's uid, so the snapshot stays on disk until the next +//! recovery save replaces it or a save of the presentation removes it. +use std::fs::File; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use serde_json::{Value, json}; @@ -25,6 +34,70 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("deckcraft/recovery")) } +/// Uids and instance ids stay below 2^53 so they survive JSON numbers read as `f64`. +pub(crate) const UID_MASK: u64 = (1 << 53) - 1; + +/// This process's random id, with the low 24 bits clear so its document uids (id + counter) don't +/// run into another instance's. +pub fn instance_id() -> u64 { + static ID: OnceLock = OnceLock::new(); + *ID.get_or_init(|| { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u32(std::process::id()); + #[cfg(not(target_arch = "wasm32"))] + if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + h.write_u128(d.as_nanos()); + } + // Never 0: old entries are numbered from 1. + ((h.finish() & UID_MASK) & !0xFF_FFFF).max(1 << 24) + }) +} + +fn lock_path(dir: &Path, owner: u64) -> PathBuf { + dir.join(format!("owner-{owner}.lock")) +} + +/// Hold this instance's lock in `dir` for the rest of the process (once per folder). +fn claim_dir(dir: &Path) { + static HELD: Mutex> = Mutex::new(Vec::new()); + let Ok(mut held) = HELD.lock() else { return }; + if held.iter().any(|(d, _)| d == dir) { + return; + } + let path = lock_path(dir, instance_id()); + match File::create(&path).and_then(|f| f.try_lock().map(|()| f).map_err(std::io::Error::other)) { + Ok(f) => held.push((dir.to_path_buf(), f)), + Err(e) => log::warn!("recovery: could not lock {}: {e}", path.display()), + } +} + +/// Whether another running instance holds `owner`'s lock in `dir`. +fn owner_alive(dir: &Path, owner: u64) -> bool { + let Ok(f) = File::open(lock_path(dir, owner)) else { return false }; + matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)) +} + +/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn. +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or(""))); + std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display()))) +} + +/// Presentations reopened per start; the rest stay on disk for the next one, so a folder +/// full of entries can't stall start-up. +pub const MAX_REOPEN: usize = 32; +/// Largest entry read back. +const MAX_ENTRY_BYTES: u64 = 2 << 30; +const MAX_META_BYTES: u64 = 64 << 10; + +/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an +/// entry belongs would block start-up forever. +fn read_regular(p: &Path, max: u64) -> Option> { + let m = std::fs::metadata(p).ok()?; + (m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten() +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json"))) } @@ -51,6 +124,7 @@ pub fn discard(dir: &Path, uid: u64) { /// were written. pub fn save(s: &Session, dir: &Path) -> Result { std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + claim_dir(dir); let mut n = 0; for d in s.documents() { if !d.is_dirty() { @@ -59,11 +133,9 @@ pub fn save(s: &Session, dir: &Path) -> Result { } let (doc, meta) = files(dir, d.uid); let bytes = deckcraft_format::save(&d.doc).map_err(|e| EngineError::Other(e.to_string()))?; - // Write then rename, so a crash mid-write never leaves a torn file. - let tmp = doc.with_extension("tmp"); - std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?; - let m = json!({"path": d.path, "title": d.title(), "saved": now_secs()}); - std::fs::write(&meta, m.to_string()).map_err(|e| EngineError::Other(format!("{}: {e}", meta.display())))?; + write_atomic(&doc, &bytes)?; + let m = json!({"path": d.path, "title": d.title(), "saved": now_secs(), "owner": instance_id()}); + write_atomic(&meta, m.to_string().as_bytes())?; n += 1; } Ok(n) @@ -78,11 +150,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") { continue; } + if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) { + continue; + } let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. - let meta = std::fs::read_to_string(p.with_extension("json")) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) + let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES) + .and_then(|t| serde_json::from_slice::(&t).ok()) .filter(Value::is_object) .unwrap_or(json!({})); out.push((uid, meta)); @@ -91,27 +165,73 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { out } -/// Reopen every entry as an unsaved presentation and remove the entries (this session writes its -/// own). Unreadable entries are left in place. Returns the opened documents' indices. +/// Reopen every entry left by an instance that is no longer running as an unsaved presentation. +/// Entries stay on disk, now owned by this instance: the presentation keeps the entry's uid, so +/// the next recovery save replaces the snapshot and saving or closing it removes it. Entries of a +/// running instance, unreadable entries and entries already open here are left alone. Returns the +/// opened documents' indices. At most [`MAX_REOPEN`] are opened per start, the newest first; the rest +/// wait for the next one. pub fn open(s: &mut Session, dir: &Path) -> Result> { + let me = instance_id(); let mut opened = Vec::new(); - for (uid, meta) in list(dir) { - let (doc, _) = files(dir, uid); - let Ok(bytes) = std::fs::read(&doc) else { continue }; + let mut entries = list(dir); + entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid))); + for (uid, mut meta) in entries { + if opened.len() >= MAX_REOPEN { + break; + } + if s.documents().iter().any(|d| d.uid == uid) { + continue; + } + if let Some(owner) = meta.get("owner").and_then(Value::as_u64) + && owner != me + && owner_alive(dir, owner) + { + continue; + } + let (doc, meta_path) = files(dir, uid); + let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue }; let Ok(mut d) = deckcraft_format::load(&bytes) else { continue }; deckcraft_format::repair(&mut d); let path = meta.get("path").and_then(Value::as_str).map(str::to_string); - let title = meta.get("title").and_then(Value::as_str).filter(|t| !t.is_empty()).unwrap_or("Presentation"); + let title = meta.get("title").and_then(Value::as_str).filter(|t| !t.is_empty()).unwrap_or("Presentation").to_string(); + // Take the entry over (so another instance starting now leaves it alone), keeping its + // snapshot until this one writes a newer one. + claim_dir(dir); + if let Some(o) = meta.as_object_mut() { + o.insert("owner".into(), json!(me)); + } + if let Err(e) = write_atomic(&meta_path, meta.to_string().as_bytes()) { + log::warn!("recovery: {e}"); + } let mut st = DocState::new(d, path, format!("{title} (Recovered)")); + st.uid = uid; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; opened.push(s.add_document(st)); - discard(dir, uid); } + remove_stale_locks(dir); Ok(opened) } +/// Delete the lock files of instances that are no longer running. +fn remove_stale_locks(dir: &Path) { + let me = instance_id(); + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let name = e.file_name(); + let Some(owner) = + name.to_str().and_then(|n| n.strip_prefix("owner-")).and_then(|n| n.strip_suffix(".lock")).and_then(|n| n.parse::().ok()) + else { + continue; + }; + if owner != me && !owner_alive(dir, owner) { + let _ = std::fs::remove_file(e.path()); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -139,16 +259,67 @@ mod tests { assert!(st.is_dirty()); assert_eq!(st.doc.slides.len(), 2); assert!(st.title().ends_with("(Recovered)")); - assert!(list(&dir).is_empty(), "entries are consumed"); - // Saving drops the entry. - s.recovery_dir = Some(dir.clone()); - assert_eq!(save(&s, &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1, "the snapshot stays until it is replaced or saved"); + // A second "crash" before any checkpoint: the presentation is recovered again. + let mut s3 = Session::new(); + assert_eq!(open(&mut s3, &dir).unwrap().len(), 1); + assert_eq!(s3.doc().unwrap().doc.slides.len(), 2); + // Opening again in the same session doesn't duplicate it. + assert!(open(&mut s3, &dir).unwrap().is_empty()); + // Saving the recovered presentation drops the entry. + s3.recovery_dir = Some(dir.clone()); let path = dir.join("saved.deckcraft"); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + s3.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); assert!(list(&dir).is_empty()); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn uids_are_unique_beyond_this_process() { + let a = DocState::new(Default::default(), None, "a".into()); + let b = DocState::new(Default::default(), None, "b".into()); + assert_ne!(a.uid, b.uid); + // Not the small per-process numbers every instance used to start from. + assert!(a.uid >= 1 << 24 && a.uid <= UID_MASK); + } + + #[test] + fn another_running_instance_keeps_its_entries() { + let dir = temp("recovery-shared"); + std::fs::create_dir_all(&dir).unwrap(); + // Another instance (its own owner id and uid, its lock held) has an unsaved presentation. + let other = instance_id() ^ (1 << 40); + let lock = File::create(lock_path(&dir, other)).unwrap(); + lock.try_lock().unwrap(); + let mut o = Session::new(); + o.execute("file.new", &json!({})).unwrap(); + o.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let bytes = deckcraft_format::save(&o.doc().unwrap().doc).unwrap(); + let uid = other + 5; + let (d, m) = files(&dir, uid); + std::fs::write(&d, bytes).unwrap(); + std::fs::write(&m, json!({"title": "Theirs", "owner": other}).to_string()).unwrap(); + // This instance saving its clean and dirty presentations leaves that entry alone... + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(save(&s, &dir).unwrap(), 1); + assert!(list(&dir).iter().any(|e| e.0 == uid)); + // ...and a recovery open here skips it while its owner runs. + let mut s2 = Session::new(); + let opened = open(&mut s2, &dir).unwrap(); + assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}"); + assert!(lock_path(&dir, other).exists(), "a live owner's lock stays"); + // Once the owner is gone, its entry is recovered and its lock file cleaned up. + drop(lock); + let mut s3 = Session::new(); + open(&mut s3, &dir).unwrap(); + assert!(s3.documents().iter().any(|d| d.uid == uid && d.title().starts_with("Theirs"))); + assert!(!lock_path(&dir, other).exists()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn corrupt_entries_are_skipped() { let dir = temp("recovery-corrupt"); @@ -162,4 +333,36 @@ mod tests { assert_eq!(v.as_array().map(Vec::len), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + + /// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked + /// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start. + #[cfg(unix)] + #[test] + fn hostile_recovery_folders_dont_stall_start_up() { + let dir = temp("recovery-hostile"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + save(&s, &dir).unwrap(); + let (good, _) = files(&dir, s.doc().unwrap().uid); + for i in 1..=(MAX_REOPEN as u64 + 3) { + std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap(); + } + for name in ["5.deckcraft", "6.json"] { + let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status(); + } + std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let d = dir.clone(); + std::thread::spawn(move || { + let mut s2 = Session::new(); + let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0)); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung"); + assert_eq!(n, MAX_REOPEN); + // Every entry stays on disk (main keeps snapshots until a newer save); the 32 opened plus + // the rest that wait for the next start. + assert_eq!(list(&dir).len(), MAX_REOPEN + 5, "the rest wait for the next start"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/soak_tests.rs b/crates/engine/src/soak_tests.rs new file mode 100644 index 00000000..9c70d155 --- /dev/null +++ b/crates/engine/src/soak_tests.rs @@ -0,0 +1,179 @@ +//! Editor soak: long random command sequences over a big deck, with invariants checked after +//! every step: no panic, a valid document, an in-range selection, a bounded undo stack, undo then +//! redo restoring the exact document, and periodic save/reopen in both formats. +//! +//! Seeded and deterministic. A failure prints the seed and the last commands so it replays. +//! `DECKCRAFT_SOAK_STEPS=50000 cargo test -p deckcraft-engine soak -- --ignored` runs a long soak. + +use std::collections::VecDeque; + +use serde_json::{Value, json}; + +use crate::*; + +/// Commands that touch the file system, other windows or real time; the soak drives the rest. +fn skipped(id: &str) -> bool { + matches!(id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.export" | "file.saveTemplate" | "file.openBytes" | "file.new") + || id.starts_with("window.") + || id.starts_with("show.") + || id.starts_with("media.") +} + +/// xorshift64*: tiny, deterministic, no dependency. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn f(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo) + } + fn pick<'a, T>(&mut self, v: &'a [T]) -> Option<&'a T> { + v.get(self.below(v.len())) + } +} + +fn shape_ids(s: &Session) -> Vec { + s.active().map(|d| d.shapes().iter().map(|x| u64::from(x.id.0)).collect()).unwrap_or_default() +} + +/// Plausible parameters built from the live document, plus the odd hostile value. +fn params(r: &mut Rng, s: &Session) -> Value { + let ids = shape_ids(s); + let id = r.pick(&ids).copied().unwrap_or(1); + let slides = s.active().map(|d| d.doc.slides.len()).unwrap_or(1); + let index = r.below(slides + 2); + match r.below(10) { + 0 => json!({}), + 1 => json!({"ids": [id]}), + 2 => json!({"id": id, "text": "Soak ✓ שלום 漢字", "name": "S"}), + 3 => json!({"index": index, "to": r.below(slides + 1)}), + 4 => json!({"preset": "rect", "rect": [r.f(0.0, 600.0), r.f(0.0, 400.0), r.f(1.0, 300.0), r.f(1.0, 200.0)]}), + 5 => json!({"ids": [id], "color": "#3366CC", "size": r.f(1.0, 96.0), "value": r.f(-10.0, 10.0)}), + 6 => json!({"ids": ids.iter().take(3).collect::>()}), + 7 => json!({"effect": "fade", "start": "afterPrevious", "duration": r.below(5000), "delay": r.below(2000)}), + 8 => json!({"layout": "titleAndContent", "title": "T", "body": "B"}), + _ => json!({"index": u64::MAX, "ids": [u64::MAX], "id": -1, "size": 1e308, "rect": [f64::MAX, 0, -1, 0]}), + } +} + +fn doc(s: &Session) -> Option { + s.active().map(|d| (*d.doc).clone()) +} + +fn check(s: &Session, seed: u64, step: usize, log: &VecDeque) { + let ctx = || format!("seed {seed} step {step}; last commands:\n{}", log.iter().cloned().collect::>().join("\n")); + let Some(d) = s.active() else { panic!("no active document; {}", ctx()) }; + let problems = d.doc.validate(); + assert!(problems.is_empty(), "{problems:?}; {}", ctx()); + assert!(d.selection.slide < d.doc.slides.len().max(1), "selection slide {} of {}; {}", d.selection.slide, d.doc.slides.len(), ctx()); + assert!(d.history.undo.len() <= d.history.limit.max(1), "undo stack {} over limit; {}", d.history.undo.len(), ctx()); +} + +fn soak(seed: u64, steps: usize) { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + // A big deck: the sample several times over. + for _ in 0..3 { + let n = s.doc().unwrap().doc.slides.len(); + for i in 0..n { + let _ = s.execute("slide.duplicate", &json!({"index": i})); + } + } + let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).filter(|id| !skipped(id)).collect(); + let mut r = Rng(seed | 1); + let mut log: VecDeque = VecDeque::new(); + let mut edits = 0usize; + for step in 0..steps { + let id = *r.pick(&ids).unwrap(); + let p = params(&mut r, &s); + log.push_back(format!("{id} {p}")); + if log.len() > 20 { + log.pop_front(); + } + let before = doc(&s); + let undoable = command_specs().iter().any(|c| c.id == id && c.undoable); + let res = s.execute(id, &p); + if let Err(EngineError::Internal(..)) = &res { + panic!("command panicked (caught by the guard): {res:?}; seed {seed} step {step}; {id} {p}"); + } + let ok = res.is_ok(); + check(&s, seed, step, &log); + // Undo then redo of a real edit gets the same document back. + let after = doc(&s); + if before != after { + edits += 1; + } + if ok && undoable && before != after && r.below(4) == 0 { + s.execute("edit.undo", &json!({})).unwrap_or_else(|e| panic!("undo failed: {e}; seed {seed} step {step} {id} {p}")); + assert!(doc(&s) == before, "undo didn't restore; seed {seed} step {step} {id} {p}"); + s.execute("edit.redo", &json!({})).unwrap_or_else(|e| panic!("redo failed: {e}; seed {seed} step {step} {id} {p}")); + assert!(doc(&s) == after, "redo didn't reapply; seed {seed} step {step} {id} {p}"); + } + // Keep the deck from vanishing or exploding. + let n = s.doc().map(|d| d.doc.slides.len()).unwrap_or(0); + if n == 0 || s.documents().len() != 1 { + panic!("documents {} slides {n}; seed {seed} step {step}; {log:?}", s.documents().len()); + } + if step % 100 == 99 { + for format in ["deckcraft", "pptx"] { + let data = + s.execute("file.saveBytes", &json!({"format": format})).unwrap_or_else(|e| panic!("save {format}: {e}; seed {seed} step {step}")); + let b64 = data["data"].as_str().unwrap_or_default().to_string(); + s.execute("file.openBytes", &json!({"name": format!("soak.{format}"), "data": b64})) + .unwrap_or_else(|e| panic!("reopen {format}: {e}; seed {seed} step {step}; {log:?}")); + let back = s.doc().unwrap().doc.slides.len(); + assert_eq!(back, n, "{format} reopen slide count; seed {seed} step {step}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{format} reopen invalid; seed {seed} step {step}"); + let last = s.documents().len() - 1; + s.close_document(last); + s.set_active(0); + } + } + } + // The soak must really edit, not just bounce off disabled commands. + assert!(edits * 10 >= steps, "only {edits} of {steps} steps changed the document; seed {seed}"); + eprintln!("soak seed {seed}: {edits} of {steps} steps edited"); +} + +fn steps(default: usize) -> usize { + std::env::var("DECKCRAFT_SOAK_STEPS").ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +#[test] +fn soak_random_edits_keep_the_document_valid() { + for seed in [1, 0xDEC0_C2AF, 42, 7_777_777] { + soak(seed, steps(400)); + } +} + +#[test] +#[ignore = "long soak; set DECKCRAFT_SOAK_STEPS"] +fn soak_long() { + let seed = std::env::var("DECKCRAFT_SOAK_SEED").ok().and_then(|v| v.parse().ok()).unwrap_or(0x5EED); + soak(seed, steps(20_000)); +} + +#[test] +fn undo_stack_stays_bounded_and_drains() { + let mut s = Session::with_new(); + let limit = s.doc().unwrap().history.limit; + for i in 0..limit + 50 { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [i % 500, 10, 20, 20]})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), limit); + let mut undone = 0; + while s.execute("edit.undo", &json!({})).is_ok() { + undone += 1; + assert!(undone <= limit, "undo ran past the stack"); + } + assert_eq!(undone, limit); + assert!(s.doc().unwrap().doc.validate().is_empty()); +} diff --git a/crates/engine/src/sounds.rs b/crates/engine/src/sounds.rs new file mode 100644 index 00000000..8b97c758 --- /dev/null +++ b/crates/engine/src/sounds.rs @@ -0,0 +1,107 @@ +//! Built-in transition sounds, synthesised on demand so DeckCraft ships no sound files: short +//! mono 16-bit WAVs named like PowerPoint's (Chime, Click, Camera, Whoosh, Drum Roll, Laser). + +use std::f64::consts::TAU; + +/// (id, label) of the built-in sounds, in menu order. +pub const BUILTIN: [(&str, &str); 6] = + [("chime", "Chime"), ("click", "Click"), ("camera", "Camera"), ("whoosh", "Whoosh"), ("drumRoll", "Drum Roll"), ("laser", "Laser")]; + +const RATE: u32 = 22_050; + +/// The WAV bytes of built-in sound `id`, or `None` for an unknown id. +pub fn builtin(id: &str) -> Option> { + let mut noise = Lcg(0x9E37_79B9); + let samples: Vec = match id { + "chime" => tone(1.2, |t| { + let env = (-t * 3.0).exp() * attack(t, 0.005); + env * (0.6 * (TAU * 880.0 * t).sin() + 0.3 * (TAU * 1318.5 * t).sin() + 0.15 * (TAU * 1760.0 * t).sin()) + }), + "click" => tone(0.03, |t| (-t * 300.0).exp() * noise.next()), + "camera" => tone(0.35, |t| { + let a = (-t * 250.0).exp(); + let b = if t > 0.18 { (-(t - 0.18) * 180.0).exp() } else { 0.0 }; + (a + 0.8 * b) * noise.next() + }), + "whoosh" => { + let mut lp = 0.0; + tone(0.7, |t| { + // Noise through a low-pass filter that opens and closes. + let k = 0.02 + 0.25 * (std::f64::consts::PI * t / 0.7).sin(); + lp += k * (noise.next() - lp); + 3.0 * lp * (std::f64::consts::PI * t / 0.7).sin() + }) + } + "drumRoll" => tone(1.4, |t| { + let beat = (t * 24.0).fract() / 24.0; + let swell = 0.4 + 0.6 * (t / 1.4); + swell * (-beat * 90.0).exp() * (0.7 * noise.next() + 0.5 * (TAU * 110.0 * t).sin()) + }), + "laser" => { + let mut phase = 0.0; + tone(0.5, |t| { + phase += TAU * (2400.0 * (-t * 5.0).exp() + 200.0) / RATE as f64; + (-t * 4.0).exp() * attack(t, 0.003) * phase.sin().signum() * 0.35 + }) + } + _ => return None, + }; + Some(wav(&samples)) +} + +fn attack(t: f64, len: f64) -> f64 { + (t / len).min(1.0) +} + +fn tone(secs: f64, mut f: impl FnMut(f64) -> f64) -> Vec { + let n = (secs * RATE as f64) as usize; + (0..n).map(|i| f(i as f64 / RATE as f64)).collect() +} + +/// A small deterministic noise source (-1..1). +struct Lcg(u32); +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (self.0 >> 8) as f64 / (1u32 << 23) as f64 - 1.0 + } +} + +fn wav(samples: &[f64]) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut v = Vec::with_capacity(44 + data_len as usize); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_len).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); // PCM + v.extend_from_slice(&1u16.to_le_bytes()); // mono + v.extend_from_slice(&RATE.to_le_bytes()); + v.extend_from_slice(&(RATE * 2).to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&16u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + let x = (s.clamp(-1.0, 1.0) * 0.8 * i16::MAX as f64) as i16; + v.extend_from_slice(&x.to_le_bytes()); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_sound_is_a_playable_wav() { + for (id, _) in BUILTIN { + let bytes = builtin(id).unwrap_or_default(); + assert!(bytes.len() > 44, "{id}"); + let info = deckcraft_media::probe(&std::sync::Arc::new(bytes)).expect(id); + assert!(info.audio.is_some() && info.playable(), "{id}"); + assert!(info.duration() > 0.01 && info.duration() < 2.0, "{id}: {}", info.duration()); + } + assert!(builtin("nope").is_none()); + } +} diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs new file mode 100644 index 00000000..2c947835 --- /dev/null +++ b/crates/engine/src/spell.rs @@ -0,0 +1,912 @@ +//! Spell checking with Hunspell-format dictionaries (`xx_YY.aff` + `xx_YY.dic`). +//! +//! A subset of Hunspell: `SET` (UTF-8 and ISO-8859-1), `FLAG` (default, `long`, `num`, `UTF-8`), +//! `PFX`/`SFX` with strip, conditions and cross products, continuation flags on affixes (one +//! level: a suffix that allows a further suffix), `NEEDAFFIX`, `FORBIDDENWORD`, `NOSUGGEST`, +//! `KEEPCASE`, `TRY`, `REP` and `WORDCHARS`. Compounding, `ICONV`/`OCONV` and morphology are not +//! read; words they would accept are reported as misspelt. +//! +//! No dictionary ships with DeckCraft. They are looked up in the folders the system's own spell +//! checkers use (and `DECKCRAFT_DICTIONARIES`), so whatever the user installed is used. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +type Flag = u32; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum FlagMode { + Char, + Long, + Num, +} + +#[derive(Clone, Debug)] +enum Cond { + Any, + Is(char), + In(Vec, bool), +} + +impl Cond { + fn matches(&self, c: char) -> bool { + match self { + Cond::Any => true, + Cond::Is(x) => *x == c, + Cond::In(set, neg) => set.contains(&c) != *neg, + } + } +} + +fn parse_cond(s: &str) -> Vec { + let mut out = vec![]; + let mut it = s.chars(); + while let Some(c) = it.next() { + match c { + '.' => out.push(Cond::Any), + '[' => { + let mut set = vec![]; + let mut neg = false; + for (i, d) in it.by_ref().enumerate() { + if d == ']' { + break; + } + if i == 0 && d == '^' { + neg = true; + } else { + set.push(d); + } + } + out.push(Cond::In(set, neg)); + } + c => out.push(Cond::Is(c)), + } + } + out +} + +#[derive(Clone, Debug)] +struct Affix { + flag: Flag, + cross: bool, + strip: String, + add: String, + cond: Vec, + cont: Vec, +} + +impl Affix { + /// Prefix: does `root` start with the condition? Suffix: does it end with it? + fn cond_ok(&self, root: &[char], prefix: bool) -> bool { + if self.cond.len() > root.len() { + return false; + } + if prefix { + self.cond.iter().zip(root).all(|(c, r)| c.matches(*r)) + } else { + self.cond.iter().rev().zip(root.iter().rev()).all(|(c, r)| c.matches(*r)) + } + } +} + +/// A loaded dictionary. +#[derive(Debug, Default)] +pub struct Dictionary { + words: HashMap>>, + pfx: Vec, + sfx: Vec, + try_chars: Vec, + rep: Vec<(String, String)>, + word_chars: Vec, + forbidden: Option, + need_affix: Option, + no_suggest: Option, + keep_case: Option, + only_in_compound: Option, + compound: Option, + compound_begin: Option, + compound_middle: Option, + compound_end: Option, + compound_min: usize, + compound_max: usize, +} + +fn decode(bytes: &[u8], latin1: bool) -> String { + if latin1 { bytes.iter().map(|b| char::from(*b)).collect() } else { String::from_utf8_lossy(bytes).into_owned() } +} + +fn parse_flags(s: &str, mode: FlagMode) -> Vec { + match mode { + FlagMode::Char => s.chars().map(|c| c as u32).collect(), + FlagMode::Long => { + let c: Vec = s.chars().collect(); + c.chunks(2).map(|p| p.iter().fold(0u32, |a, c| a.wrapping_mul(0x11_0000).wrapping_add(*c as u32))).collect() + } + FlagMode::Num => s.split(',').filter_map(|n| n.trim().parse().ok()).collect(), + } +} + +fn one_flag(s: Option<&str>, mode: FlagMode) -> Option { + s.and_then(|s| parse_flags(s, mode).first().copied()) +} + +impl Dictionary { + /// Parse a dictionary from the bytes of its `.aff` and `.dic` files. + pub fn parse(aff: &[u8], dic: &[u8]) -> Dictionary { + let head = String::from_utf8_lossy(aff); + let latin1 = head.lines().any(|l| { + let mut w = l.split_whitespace(); + w.next() == Some("SET") && w.next().is_some_and(|e| e.to_ascii_uppercase().replace('-', "").starts_with("ISO8859")) + }); + let aff = decode(aff, latin1); + let mode = aff + .lines() + .find_map(|l| { + let mut w = l.split_whitespace(); + (w.next() == Some("FLAG")).then(|| match w.next() { + Some("long") => FlagMode::Long, + Some("num") => FlagMode::Num, + _ => FlagMode::Char, + }) + }) + .unwrap_or(FlagMode::Char); + let mut d = Dictionary { compound_min: 3, ..Dictionary::default() }; + let mut lines = aff.lines().map(|l| l.split('#').next().unwrap_or("")).peekable(); + while let Some(l) = lines.next() { + let w: Vec<&str> = l.split_whitespace().collect(); + match w.as_slice() { + ["TRY", s, ..] => d.try_chars = s.chars().collect(), + ["WORDCHARS", s, ..] => d.word_chars = s.chars().collect(), + ["FORBIDDENWORD", f, ..] => d.forbidden = one_flag(Some(f), mode), + ["NEEDAFFIX", f, ..] | ["PSEUDOROOT", f, ..] => d.need_affix = one_flag(Some(f), mode), + ["NOSUGGEST", f, ..] => d.no_suggest = one_flag(Some(f), mode), + ["KEEPCASE", f, ..] => d.keep_case = one_flag(Some(f), mode), + ["ONLYINCOMPOUND", f, ..] => d.only_in_compound = one_flag(Some(f), mode), + ["COMPOUNDFLAG", f, ..] => d.compound = one_flag(Some(f), mode), + ["COMPOUNDBEGIN", f, ..] => d.compound_begin = one_flag(Some(f), mode), + ["COMPOUNDMIDDLE", f, ..] => d.compound_middle = one_flag(Some(f), mode), + ["COMPOUNDEND", f, ..] | ["COMPOUNDLAST", f, ..] => d.compound_end = one_flag(Some(f), mode), + ["COMPOUNDMIN", n, ..] => d.compound_min = n.parse::().unwrap_or(3).max(1), + ["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0), + ["REP", n] => { + for _ in 0..n.parse::().unwrap_or(0) { + let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + if let [_, a, b, ..] = r.as_slice() { + d.rep.push((a.replace('_', " "), b.replace('_', " "))); + } + } + } + [kind @ ("PFX" | "SFX"), flag, cross, n] if n.parse::().is_ok() => { + let prefix = *kind == "PFX"; + let Some(flag) = one_flag(Some(flag), mode) else { continue }; + let cross = cross.eq_ignore_ascii_case("y"); + for _ in 0..n.parse::().unwrap_or(0) { + let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect(); + let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue }; + let (add, cont) = match add.split_once('/') { + Some((a, c)) => (a, parse_flags(c, mode)), + None => (*add, vec![]), + }; + let a = Affix { + flag, + cross, + strip: if *strip == "0" { String::new() } else { strip.to_string() }, + add: if add == "0" { String::new() } else { add.to_string() }, + cond: parse_cond(rest.first().copied().unwrap_or(".")), + cont, + }; + if prefix { d.pfx.push(a) } else { d.sfx.push(a) } + } + } + _ => {} + } + } + let dic = decode(dic, latin1); + for (i, l) in dic.lines().enumerate() { + if i == 0 && l.trim().parse::().is_ok() { + continue; + } + let entry = l.split(['\t']).next().unwrap_or("").trim_end(); + // Morphological fields follow a space (`word/flags po:noun`). + let entry = entry.split(' ').next().unwrap_or(""); + if entry.is_empty() { + continue; + } + let (word, flags) = match entry.split_once('/') { + Some((w, f)) if !w.is_empty() => (w, parse_flags(f, mode)), + _ => (entry, vec![]), + }; + d.words.entry(word.to_string()).or_default().push(flags); + } + d + } + + /// Add `word` (no flags), as a user's own dictionary entry. + pub fn add_word(&mut self, word: &str) { + self.words.entry(word.to_string()).or_default().push(vec![]); + } + + fn has(&self, flags: &[Flag], f: Option) -> bool { + f.is_some_and(|f| flags.contains(&f)) + } + + /// `word` as a root carrying all of `need` (and not forbidden). + fn root(&self, word: &str, need: &[Flag], affixed: bool) -> bool { + self.words.get(word).is_some_and(|hs| { + hs.iter().any(|f| { + !self.has(f, self.forbidden) + && (affixed || !self.has(f, self.need_affix)) + && !self.has(f, self.only_in_compound) + && need.iter().all(|n| f.contains(n)) + }) + }) + } + + fn forbidden_word(&self, word: &str) -> bool { + self.words.get(word).is_some_and(|hs| hs.iter().any(|f| self.has(f, self.forbidden))) + } + + /// Strip suffixes from `w` (one, or two through continuation flags); `extra` flags must also be + /// on the root, `cross` means the suffix must allow a prefix. + fn suffixed(&self, w: &[char], extra: &[Flag], cross: bool) -> bool { + for s in &self.sfx { + if cross && !s.cross { + continue; + } + let Some(stem) = strip_end(w, &s.add, &s.strip) else { continue }; + if !s.cond_ok(&stem, false) { + continue; + } + let root: String = stem.iter().collect(); + let mut need = extra.to_vec(); + need.push(s.flag); + if self.root(&root, &need, true) { + return true; + } + // A second suffix whose continuation allows this one (`walk/A` + `ing/B`…). + for s2 in &self.sfx { + if !s.cont.is_empty() || !s2.cont.contains(&s.flag) { + continue; + } + let Some(stem2) = strip_end(&stem, &s2.add, &s2.strip) else { continue }; + if s2.cond_ok(&stem2, false) { + let mut need = extra.to_vec(); + need.push(s2.flag); + if self.root(&stem2.iter().collect::(), &need, true) { + return true; + } + } + } + } + false + } + + /// A root (or, for the last part, a suffixed root) carrying `flag`, as a compound part. + fn compound_part(&self, part: &[char], flag: Flag, last: bool) -> bool { + let word: String = part.iter().collect(); + let ok = self.words.get(&word).is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag))); + ok || (last && self.suffixed_compound_end(part, flag)) + } + + /// The last part of a compound with a suffix: the root must carry the compound `flag`. + fn suffixed_compound_end(&self, w: &[char], flag: Flag) -> bool { + self.sfx.iter().any(|s| { + strip_end(w, &s.add, &s.strip).is_some_and(|stem| { + s.cond_ok(&stem, false) + && self + .words + .get(&stem.iter().collect::()) + .is_some_and(|hs| hs.iter().any(|f| !self.has(f, self.forbidden) && f.contains(&flag) && f.contains(&s.flag))) + }) + }) + } + + /// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and + /// end flags), each at least `COMPOUNDMIN` characters long. + fn compounded(&self, w: &[char], index: usize) -> bool { + if self.compound.is_none() && self.compound_end.is_none() { + return false; + } + let min = self.compound_min.max(1); + if index >= 8 || (self.compound_max > 0 && index + 1 >= self.compound_max) || w.len() < 2 * min { + return false; + } + let role = if index == 0 { self.compound_begin } else { self.compound_middle }; + for i in min..=w.len() - min { + let (head, rest) = w.split_at(i); + let head_ok = [self.compound, role].into_iter().flatten().any(|f| self.compound_part(head, f, false)); + if !head_ok { + continue; + } + if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) { + return true; + } + if self.compounded(rest, index + 1) { + return true; + } + } + false + } + + fn check_exact(&self, word: &str) -> bool { + if self.forbidden_word(word) { + return false; + } + if self.root(word, &[], false) { + return true; + } + let w: Vec = word.chars().collect(); + if self.suffixed(&w, &[], false) { + return true; + } + for p in &self.pfx { + let Some(stem) = strip_start(&w, &p.add, &p.strip) else { continue }; + if !p.cond_ok(&stem, true) { + continue; + } + if self.root(&stem.iter().collect::(), &[p.flag], true) { + return true; + } + if p.cross && self.suffixed(&stem, &[p.flag], true) { + return true; + } + } + self.compounded(&w, 0) + } + + /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. + pub fn check(&self, word: &str) -> bool { + if word.is_empty() || word.chars().all(|c| !c.is_alphabetic()) { + return true; + } + if self.check_exact(word) { + return true; + } + let lower = word.to_lowercase(); + let upper_tail = word.chars().skip(1).all(|c| !c.is_lowercase()); + let cap_only = word.chars().next().is_some_and(char::is_uppercase) && word.chars().skip(1).all(|c| !c.is_uppercase()); + let keep = |w: &str| self.words.get(w).is_some_and(|hs| hs.iter().any(|f| self.has(f, self.keep_case))); + if (cap_only || upper_tail) && !keep(&lower) && self.check_exact(&lower) { + return true; + } + if upper_tail && !cap_only { + let cap = capitalise(&lower); + if !keep(&cap) && self.check_exact(&cap) { + return true; + } + } + // A trailing full stop on an abbreviation (`etc.`) is part of the word in some dictionaries. + false + } + + fn suggestible(&self, w: &str) -> bool { + self.check(w) && !self.words.get(w).is_some_and(|hs| hs.iter().all(|f| self.has(f, self.no_suggest) && !f.is_empty())) + } + + /// Up to `n` corrections for a misspelt `word`, best first. + pub fn suggest(&self, word: &str, n: usize) -> Vec { + // Edit-distance candidates grow with the word: nothing is suggested for a run-on token. + if word.chars().count() > MAX_SUGGEST_CHARS { + return vec![]; + } + let cap = word.chars().next().is_some_and(char::is_uppercase); + let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() }; + let w: Vec = lower.chars().collect(); + let mut out: Vec = vec![]; + let mut seen = HashSet::new(); + let mut push = |s: String, out: &mut Vec| { + if s != lower && seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) { + out.push(s); + } + }; + for (a, b) in &self.rep { + let mut from = 0; + while let Some(i) = lower.get(from..).and_then(|t| t.find(a.as_str())) { + let at = from + i; + push(format!("{}{}{}", &lower[..at], b, &lower[at + a.len()..]), &mut out); + from = at + a.len().max(1); + } + } + let try_chars: Vec = if self.try_chars.is_empty() { "esianrtolcdugmphbyfvkwzxjq".chars().collect() } else { self.try_chars.clone() }; + let s = |v: &[char]| v.iter().collect::(); + for i in 0..w.len().saturating_sub(1) { + let mut v = w.clone(); + v.swap(i, i + 1); + push(s(&v), &mut out); + } + for i in 0..w.len() { + let mut v = w.clone(); + v.remove(i); + push(s(&v), &mut out); + } + for i in 0..w.len() { + for c in &try_chars { + if *c != w[i] { + let mut v = w.clone(); + v[i] = *c; + push(s(&v), &mut out); + } + } + } + for i in 0..=w.len() { + for c in &try_chars { + let mut v = w.clone(); + v.insert(i, *c); + push(s(&v), &mut out); + } + } + for i in 1..w.len() { + push(format!("{} {}", s(&w[..i]), s(&w[i..])), &mut out); + } + out.truncate(n); + if cap { + out = out.into_iter().map(|x| capitalise(&x)).collect(); + } + out + } + + /// Characters that belong inside words besides letters and digits (the `WORDCHARS` list). + pub fn word_chars(&self) -> &[char] { + &self.word_chars + } +} + +fn capitalise(s: &str) -> String { + let mut c = s.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() +} + +fn strip_end(w: &[char], add: &str, strip: &str) -> Option> { + let add: Vec = add.chars().collect(); + if w.len() <= add.len() && strip.is_empty() || !w.ends_with(&add) { + return None; + } + let mut v = w.get(..w.len() - add.len())?.to_vec(); + v.extend(strip.chars()); + (!v.is_empty()).then_some(v) +} + +fn strip_start(w: &[char], add: &str, strip: &str) -> Option> { + let add: Vec = add.chars().collect(); + if w.len() <= add.len() && strip.is_empty() || !w.starts_with(&add) { + return None; + } + let mut v: Vec = strip.chars().collect(); + v.extend_from_slice(w.get(add.len()..)?); + (!v.is_empty()).then_some(v) +} + +/// The folders searched for dictionaries, in order. +pub fn search_dirs() -> Vec { + let mut out = vec![]; + if let Some(v) = std::env::var_os("DECKCRAFT_DICTIONARIES") { + out.extend(std::env::split_paths(&v)); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + out.extend(home.iter().map(|h| h.join("Library/Application Support/DeckCraft/Dictionaries"))); + out.extend(home.iter().map(|h| h.join("Library/Spelling"))); + out.push("/Library/Spelling".into()); + } else if cfg!(target_os = "windows") { + out.extend(std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DeckCraft").join("Dictionaries"))); + } else { + let data = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local/share"))); + out.extend(data.iter().map(|d| d.join("deckcraft/dictionaries"))); + out.extend(data.iter().map(|d| d.join("hunspell"))); + for d in ["/usr/share/hunspell", "/usr/share/myspell", "/usr/share/myspell/dicts", "/usr/local/share/hunspell", "/app/share/hunspell"] { + out.push(d.into()); + } + } + out +} + +/// `en-US` → `en_US`; the language alone (`en`) as a fallback. +fn names(lang: &str) -> (String, String) { + let full = lang.replace('-', "_"); + let base = full.split('_').next().unwrap_or("").to_lowercase(); + (full, base) +} + +/// A language tag as dictionaries are named: 1–16 ASCII letters, digits, `-` or `_`. +fn valid_lang(lang: &str) -> bool { + (1..=16).contains(&lang.len()) && lang.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// The `.aff`/`.dic` pair for `lang` in `dirs`: the exact locale first, then any of its language. +pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> { + // A language tag names a file in the dictionary folders and nothing else: no `..`, no separators. + if !valid_lang(lang) { + return None; + } + let (full, base) = names(lang); + let pair = |d: &Path, stem: &str| { + let (a, b) = (d.join(format!("{stem}.aff")), d.join(format!("{stem}.dic"))); + (a.is_file() && b.is_file()).then_some((a, b)) + }; + for d in dirs { + if let Some(p) = pair(d, &full) { + return Some(p); + } + } + for d in dirs { + if let Some(p) = pair(d, &base) { + return Some(p); + } + let Ok(rd) = std::fs::read_dir(d) else { continue }; + let mut stems: Vec = rd + .filter_map(|e| e.ok()?.file_name().into_string().ok()) + .filter_map(|n| n.strip_suffix(".aff").map(str::to_string)) + .filter(|n| n.to_lowercase().starts_with(&format!("{base}_"))) + .collect(); + stems.sort(); + if let Some(p) = stems.iter().find_map(|s| pair(d, s)) { + return Some(p); + } + } + None +} + +/// Longest word `Dictionary::suggest` generates corrections for. +const MAX_SUGGEST_CHARS: usize = 64; + +type Cache = Mutex>>>; + +fn cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process. +pub fn dictionary(lang: &str) -> Option> { + let cache = cache(); + let key = lang.to_string(); + if let Ok(c) = cache.lock() + && let Some(d) = c.get(&key) + { + return d.clone(); + } + let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| { + let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?); + Some(Arc::new(Dictionary::parse(&a, &d))) + }); + if let Ok(mut c) = cache.lock() { + c.insert(key, loaded.clone()); + } + loaded +} + +/// Where Add to Dictionary keeps words: `user.dic` in DeckCraft's own dictionaries folder, one +/// word per line (the Hunspell `.dic` format without a count). +pub fn user_dictionary_path() -> Option { + if let Ok(p) = user_override().lock() + && let Some(p) = p.clone() + { + return Some(p); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + let dir = if cfg!(target_os = "macos") { + home.map(|h| h.join("Library/Application Support/DeckCraft/Dictionaries")) + } else if cfg!(target_os = "windows") { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DeckCraft").join("Dictionaries")) + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| home.map(|h| h.join(".local/share"))) + .map(|d| d.join("deckcraft/dictionaries")) + }; + dir.map(|d| d.join("user.dic")) +} + +fn user_override() -> &'static Mutex> { + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(Default::default) +} + +struct UserWords { + words: Option>, + generation: u64, +} + +fn user_state() -> &'static Mutex { + static U: OnceLock> = OnceLock::new(); + U.get_or_init(|| Mutex::new(UserWords { words: None, generation: 0 })) +} + +/// Keep the user dictionary at `path` instead (tests, or a portable install). +pub fn set_user_dictionary_path(path: PathBuf) { + if let Ok(mut p) = user_override().lock() { + *p = Some(path); + } + if let Ok(mut u) = user_state().lock() { + u.words = None; + u.generation += 1; + } +} + +/// Bumped whenever the user dictionary changes, so cached results can be dropped. +pub fn user_generation() -> u64 { + user_state().lock().map(|u| u.generation).unwrap_or(0) +} + +fn with_user(f: impl FnOnce(&HashSet) -> T) -> Option { + let mut u = user_state().lock().ok()?; + if u.words.is_none() { + let text = user_dictionary_path().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + u.words = Some(text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect()); + } + u.words.as_ref().map(f) +} + +/// Add `word` to the user dictionary file (and to checks from now on). +pub fn add_user_word(word: &str) -> std::io::Result<()> { + use std::io::Write; + let word = word.trim(); + if word.is_empty() || word.contains(['\n', '\r']) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a word")); + } + if with_user(|u| u.contains(word)).unwrap_or(false) { + return Ok(()); + } + let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?; + if let Some(d) = path.parent() { + std::fs::create_dir_all(d)?; + } + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?; + writeln!(f, "{word}")?; + if let Ok(mut u) = user_state().lock() { + if let Some(w) = u.words.as_mut() { + w.insert(word.to_string()); + } + u.generation += 1; + } + Ok(()) +} + +/// Is `word` right: in the user dictionary (as written, or lower-case), else by `d`. +pub fn is_correct(d: &Dictionary, word: &str) -> bool { + with_user(|u| u.contains(word) || u.contains(&word.to_lowercase())).unwrap_or(false) || d.check(word) +} + +/// A word of a paragraph: its characters `start..end` (paragraph character offsets, as text +/// positions count them) and the language of the run it starts in. +#[derive(Clone, Debug, PartialEq)] +pub struct Word { + pub start: usize, + pub end: usize, + pub text: String, + pub lang: String, + /// The same word twice in a row: this is the second, and deleting `cut..end` removes it with + /// the space before it. + pub repeated: Option, +} + +/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words +/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked +/// part by part. +pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec { + let mut chars: Vec<(char, usize)> = vec![]; + for (ri, r) in p.runs.iter().enumerate() { + chars.extend(r.content().chars().map(|c| (c, ri))); + } + let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string()); + let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-'; + let mut out = vec![]; + let mut i = 0; + while i < chars.len() { + if !inside(chars[i].0) { + i += 1; + continue; + } + let start = i; + while i < chars.len() && inside(chars[i].0) { + i += 1; + } + let (mut a, mut b) = (start, i); + while a < b && !chars[a].0.is_alphanumeric() { + a += 1; + } + while b > a && !chars[b - 1].0.is_alphanumeric() { + b -= 1; + } + let text: String = chars[a..b].iter().map(|c| c.0).collect(); + if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) { + continue; + } + let mut k = a; + for part in text.split('-') { + let n = part.chars().count(); + if !part.is_empty() + && let Some(c) = chars.get(k) + { + if p.runs.get(c.1).is_some_and(|r| r.props.no_proof == Some(true)) { + // Marked "do not check spelling". + } else { + out.push(Word { start: k, end: k + n, text: part.to_string(), lang: lang_of(c.1), repeated: None }); + } + } + k += n + 1; + } + } + out +} + +/// Words that repeat the word just before them (`the the`), with only spaces between, as +/// PowerPoint flags them. Hyphenated parts and different languages don't count. +pub fn repeated(p: &deckcraft_model::Paragraph, default: &str) -> Vec { + let words = paragraph_words(p, default); + let chars: Vec = p.runs.iter().flat_map(|r| r.content().chars()).collect(); + let mut out = vec![]; + for pair in words.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + let gap = chars.get(a.end..b.start).unwrap_or(&[]); + if !gap.is_empty() + && gap.iter().all(|c| *c == ' ' || *c == '\u{a0}' || *c == '\t') + && a.lang == b.lang + && a.text.to_lowercase() == b.text.to_lowercase() + { + out.push(Word { repeated: Some(a.end), ..b.clone() }); + } + } + out +} + +/// The misspelt and repeated words of `p`, each checked in its own language (languages with no dictionary +/// installed are skipped), leaving out `ignore` (lower-case). +pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]) -> Vec { + let mut dicts: HashMap>> = HashMap::new(); + paragraph_words(p, default) + .into_iter() + .filter(|w| !ignore.contains(&w.text.to_lowercase())) + .filter(|w| { + let d = dicts.entry(w.lang.clone()).or_insert_with(|| dictionary(&w.lang)); + d.as_ref().is_some_and(|d| !is_correct(d, &w.text)) + }) + .chain(repeated(p, default)) + .collect::>() + .into_iter() + .fold(vec![], |mut v: Vec, w| { + // A repeated misspelt word is reported once, as repeated. + if let Some(i) = v.iter().position(|x| x.start == w.start) { + v[i] = w; + } else { + v.push(w); + } + v + }) +} + +/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). +pub fn install(lang: &str, d: Dictionary) { + if let Ok(mut c) = cache().lock() { + c.insert(lang.to_string(), Some(Arc::new(d))); + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + // A contributor-original miniature dictionary in Hunspell format. + pub(crate) const AFF: &str = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nFORBIDDENWORD !\nNEEDAFFIX ?\nKEEPCASE K\n\ +REP 1\nREP f ph\n\ +PFX U Y 1\nPFX U 0 un .\n\ +SFX S Y 2\nSFX S y ies [^aeiou]y\nSFX S 0 s [^y]\n\ +SFX G Y 2\nSFX G e ing e\nSFX G 0 ing [^e]\n\ +SFX D Y 1\nSFX D 0 ed/S .\n"; + pub(crate) const DIC: &str = "9\nslide/S\nmake/G\nhappy\ncity/S\ndo/U\nwork/USG\nphoto/S\nNASA/K\nfoo/?S\n"; + + fn d() -> Dictionary { + Dictionary::parse(AFF.as_bytes(), DIC.as_bytes()) + } + + #[test] + fn roots_affixes_and_cross_products_check() { + let d = d(); + for w in ["slide", "slides", "making", "cities", "undo", "unworks", "working", "Slide", "SLIDES", "NASA", "foos"] { + assert!(d.check(w), "{w}"); + } + for w in ["slidez", "makeing", "citys", "unslide", "Nasa", "nasa", "foo", "teh"] { + assert!(!d.check(w), "{w}"); + } + } + + #[test] + fn a_language_tag_cannot_leave_the_dictionary_folders() { + let dir = std::env::temp_dir().join(format!("deckcraft-dict-lang-{}", std::process::id())); + let inner = dir.join("dicts"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(dir.join("evil.aff"), "SET UTF-8\n").unwrap(); + std::fs::write(dir.join("evil.dic"), "1\nword\n").unwrap(); + assert!(find_in(std::slice::from_ref(&inner), "../evil").is_none()); + assert!(find_in(std::slice::from_ref(&inner), "en/../../evil").is_none()); + assert!(!valid_lang("") && !valid_lang("a".repeat(17).as_str()) && valid_lang("en-US") && valid_lang("pt_BR")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn suggestions_come_from_edits_and_replacements() { + let d = d(); + assert_eq!(d.suggest("sldie", 3).first().map(String::as_str), Some("slide")); + assert!(d.suggest("foto", 5).contains(&"photo".to_string())); + assert!(d.suggest("Slidse", 5).contains(&"Slides".to_string())); + assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); + } + + #[test] + fn compounds_join_flagged_parts() { + let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n"; + let d = Dictionary::parse(aff.as_bytes(), b"4\nhaus/X\ntuer/XS\nschluessel/X\nfugs/OX\n".as_slice()); + for w in ["haustuer", "haustueren", "haustuerschluessel", "haus", "hausfugstuer"] { + assert!(d.check(w), "{w}"); + } + for w in ["hausx", "fugs", "tuerhausz"] { + assert!(!d.check(w), "{w}"); + } + } + + #[test] + fn runs_give_words_their_language() { + use deckcraft_model::{Paragraph, Run, RunProps}; + let p = Paragraph { + runs: vec![ + Run::with("Hello wor", RunProps { lang: Some("en-GB".into()), ..Default::default() }), + Run::with("ld, Grüße well-known 3rd l'été", RunProps { lang: Some("de-DE".into()), ..Default::default() }), + ], + ..Default::default() + }; + let w = paragraph_words(&p, "en-US"); + let got: Vec<(&str, &str, usize)> = w.iter().map(|w| (w.text.as_str(), w.lang.as_str(), w.start)).collect(); + assert_eq!( + got, + vec![ + ("Hello", "en-GB", 0), + ("world", "en-GB", 6), + ("Grüße", "de-DE", 13), + ("well", "de-DE", 19), + ("known", "de-DE", 24), + ("l'été", "de-DE", 34) + ] + ); + } + + #[test] + fn repeated_words_and_no_proof_runs() { + use deckcraft_model::{Paragraph, Run, RunProps}; + let p = Paragraph::new("See the the plan, plan. The THE end"); + let r: Vec<(usize, usize, Option)> = repeated(&p, "en-US").iter().map(|w| (w.start, w.end, w.repeated)).collect(); + assert_eq!(r, vec![(8, 11, Some(7)), (28, 31, Some(27))]); + let p = Paragraph { + runs: vec![Run::new("check "), Run::with("xyzzy qq", RunProps { no_proof: Some(true), ..Default::default() }), Run::new(" done")], + ..Default::default() + }; + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, vec!["check", "done"]); + } + + #[test] + fn long_and_numeric_flags_and_latin1() { + let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n"); + assert!(d.check("cats")); + let d = Dictionary::parse(b"FLAG num\nSFX 101 Y 1\nSFX 101 0 s .\n", b"1\ndog/7,101\n"); + assert!(d.check("dogs")); + let d = Dictionary::parse(b"SET ISO8859-1\n", b"1\ncaf\xe9\n"); + assert!(d.check("café")); + } + + #[test] + fn dictionaries_are_found_by_locale_then_language() { + let dir = std::env::temp_dir().join(format!("deckcraft-dict-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + for n in ["en_GB", "de_DE"] { + let _ = std::fs::write(dir.join(format!("{n}.aff")), ""); + let _ = std::fs::write(dir.join(format!("{n}.dic")), ""); + } + let dirs = vec![dir.clone()]; + let stem = |l: &str| find_in(&dirs, l).and_then(|(a, _)| a.file_stem().map(|s| s.to_string_lossy().into_owned())); + assert_eq!(stem("de-DE").as_deref(), Some("de_DE")); + assert_eq!(stem("en-US").as_deref(), Some("en_GB")); + assert_eq!(stem("fr-FR"), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs new file mode 100644 index 00000000..73fb0e46 --- /dev/null +++ b/crates/engine/src/templates.rs @@ -0,0 +1,337 @@ +//! Original starter presentations for File › New. +//! +//! Each deck is a few slides of our own text on one of our themes. Nothing here is copied from +//! another product's templates. + +use std::sync::Arc; + +use deckcraft_geom::Size; +use deckcraft_model::text::TextBody; +use deckcraft_model::{Presentation, defaults}; +use serde_json::Value; + +use crate::Result; +use crate::cmd::{bad, slide::find_layout}; + +/// One slide of a starter: a layout, a title, text for each non-title placeholder in order, and +/// optional speaker notes. +pub struct SlideSeed { + pub layout: &'static str, + pub title: &'static str, + pub bodies: &'static [&'static str], + pub notes: &'static str, +} + +/// A starter deck shown in the template gallery. +pub struct Starter { + pub name: &'static str, + pub summary: &'static str, + pub theme: &'static str, + pub slides: &'static [SlideSeed], +} + +const fn seed(layout: &'static str, title: &'static str, bodies: &'static [&'static str], notes: &'static str) -> SlideSeed { + SlideSeed { layout, title, bodies, notes } +} + +const STARTERS: &[Starter] = &[ + Starter { + name: "Briefing", + summary: "A weekly check-in: what changed, what's stuck, and who does the next thing", + theme: "Harbor", + slides: &[ + seed("title", "Weekly briefing", &["What changed, what's stuck, and what we do next"], "Open with what changed since last week."), + seed( + "titleAndContent", + "This week's agenda", + &["Shipped since last time\n\tOne concrete change\nStill open\n\tThe decision we need\nNext\n\tWho does it, and by when"], + "", + ), + seed("sectionHeader", "Where we are", &["A short picture before the details"], ""), + seed( + "twoContent", + "Done and next", + &[ + "Done\n\tThe change is in people's hands\n\tWe wrote down what we learned", + "Next\n\tPick the one risk to retire\n\tSay who owns it", + ], + "", + ), + seed("title", "See you next week", &["Bring one number and one question"], ""), + ], + }, + Starter { + name: "Lesson", + summary: "A short class that ends with the learner explaining the idea", + theme: "Meadow", + slides: &[ + seed("title", "How a lock works", &["A short lesson for people new to the idea"], ""), + seed( + "titleAndContent", + "By the end", + &["You can say what a lock protects\n\tAnd what it does not\nYou can sketch one in three steps"], + "", + ), + seed("sectionHeader", "The idea", &["One metaphor, then the real thing"], ""), + seed( + "titleAndContent", + "Three steps", + &["Name the thing being shared\nDecide who may change it\nLeave it so the next person can tell"], + "", + ), + seed("title", "Try it", &["Explain a lock to someone who has never seen one"], ""), + ], + }, + Starter { + name: "Pitch", + summary: "A ten-minute conversation about one promise, not a tour of features", + theme: "Ember", + slides: &[ + seed("title", "A quieter inbox", &["Ten minutes. One promise."], ""), + seed( + "titleAndContent", + "The mess", + &[ + "People miss the one message that mattered\n\tIt arrived between newsletters\nThey spend the morning sorting\n\tAnd still feel behind", + ], + "", + ), + seed("sectionHeader", "A smaller promise", &["Show the one message. Hide the rest until asked."], ""), + seed( + "comparison", + "Today and after", + &[ + "Today", + "The important note is buried\nFinding it takes a search", + "After", + "It sits at the top until you deal with it\nEverything else waits", + ], + "", + ), + seed("title", "The ask", &["Twenty people, two weeks, one question: did the right note surface?"], ""), + ], + }, + Starter { + name: "Field Notes", + summary: "What an outing set out to see, and what it actually found", + theme: "Coral Reef", + slides: &[ + seed("title", "Saturday on the ridge", &["What we went to see, and what we found"], ""), + seed("titleAndContent", "Plan", &["Leave at dawn\n\tCold, clear, little wind\nWalk the west path\nBe back before dusk"], ""), + seed("sectionHeader", "What we saw", &["Notes taken on the spot"], ""), + seed( + "twoContent", + "Expected and found", + &[ + "Expected\n\tA clear view from the ridge\n\tThe creek on the map", + "Found\n\tLow cloud on the ridge\n\tThe creek, and a better pencil", + ], + "", + ), + seed("title", "Pack list for next time", &["Warmer gloves. A second pencil. The map that shows the creek."], ""), + ], + }, + Starter { + name: "Quarterly", + summary: "A report meant to be read aloud in half an hour", + theme: "Paper", + slides: &[ + seed("title", "Spring report", &["Read aloud in thirty minutes"], ""), + seed( + "titleAndContent", + "Headlines", + &["The waiting list got shorter\n\tBy the amount we promised\nTwo things slipped\n\tBoth have a new date"], + "", + ), + seed("sectionHeader", "The numbers", &["Only the ones that change a decision"], ""), + seed("titleAndContent", "What we will stop", &["The report nobody reads\nThe meeting that repeats the report"], ""), + seed("title", "Questions we want", &["Where should the next quarter refuse work?"], ""), + ], + }, + Starter { + name: "Evening Talk", + summary: "A public talk the audience can check on the way home", + theme: "Nocturne", + slides: &[ + seed("title", "Lights out", &["A talk about how cities sleep"], ""), + seed("titleAndContent", "Road map", &["Why the sky looks orange\nWhat a dark street is for\nOne change a block can make"], ""), + seed("sectionHeader", "Look up", &["The part you can check on the way home"], ""), + seed( + "titleAndContent", + "One change", + &["Shield the lamp so light goes down\n\tNot into bedroom windows\nLeave a path you can still walk"], + "", + ), + seed("title", "Walk home", &["Count the lamps you can see from your pillow"], ""), + ], + }, +]; + +/// The starter gallery, in the order File › New shows it. +pub fn starters() -> &'static [Starter] { + STARTERS +} + +/// Starters whose name, summary, theme or a slide title contains `query` (empty matches all). +pub fn matching(query: &str) -> Vec<&'static Starter> { + let q = query.trim().to_lowercase(); + let hit = |text: &str| text.to_lowercase().contains(&q); + starters() + .iter() + .filter(|t| q.is_empty() || hit(t.name) || hit(t.summary) || hit(t.theme) || t.slides.iter().any(|slide| hit(slide.title))) + .collect() +} + +pub fn find(name: &str) -> Option<&'static Starter> { + let name = name.trim(); + starters().iter().find(|t| t.name.eq_ignore_ascii_case(name)) +} + +fn text_with_levels(text: &str) -> TextBody { + let mut body = TextBody::from_text(text); + for para in &mut body.paragraphs { + let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); + if tabs > 0 + && let Some(run) = para.runs.first_mut() + { + run.text = run.text.trim_start_matches('\t').to_string(); + para.level = tabs.min(8) as u8; + } + } + body +} + +/// A new presentation filled with `name`'s slides. `size` is the slide size in points. +pub fn build(name: &str, size: Size) -> Result { + let starter = find(name).ok_or_else(|| { + let names = starters().iter().map(|t| t.name).collect::>().join(", "); + bad("file.new", format!("no template `{name}`; use {names}")) + })?; + let theme = deckcraft_model::theme::builtin_themes() + .into_iter() + .find(|t| t.name.eq_ignore_ascii_case(starter.theme)) + .ok_or_else(|| bad("file.new", format!("template `{}` names a missing theme `{}`", starter.name, starter.theme)))?; + let mut doc = defaults::blank_presentation(size, theme, false); + for seed in starter.slides { + let key = Value::String(seed.layout.to_string()); + let layout = find_layout(&doc, &key, 0) + .ok_or_else(|| bad("file.new", format!("template `{}` uses an unknown layout `{}`", starter.name, seed.layout)))?; + let mut slide = defaults::new_slide(&mut doc, layout).ok_or_else(|| bad("file.new", crate::cmd::NO_ID_SPACE))?; + if !seed.title.is_empty() + && let Some(sh) = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())) + { + sh.text = Some(text_with_levels(seed.title)); + } + let mut bodies = seed.bodies.iter(); + for sh in slide.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| !k.is_title())) { + let Some(text) = bodies.next() else { break }; + if !text.is_empty() { + sh.text = Some(text_with_levels(text)); + } + } + if bodies.next().is_some() { + return Err(bad("file.new", format!("template `{}` has more text than the `{}` layout holds", starter.name, seed.layout))); + } + if !seed.notes.is_empty() { + slide.notes = text_with_levels(seed.notes); + } + doc.slides.push(Arc::new(slide)); + } + Ok(doc) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + fn body(doc: &Presentation, slide: usize, n: usize) -> (String, u8) { + let para_slide = &doc.slides[slide]; + let text = para_slide + .shapes + .iter() + .filter(|sh| sh.ph_type().is_some_and(|k| !k.is_title())) + .nth(n) + .and_then(|sh| sh.text.as_ref()) + .cloned() + .unwrap_or_default(); + let level = text.paragraphs.get(1).map(|p| p.level).unwrap_or(0); + (text.text(), level) + } + + #[test] + fn every_starter_opens_on_its_theme_and_is_a_clean_document() { + let mut s = Session::new(); + for starter in starters() { + s.execute("file.new", &json!({"template": starter.name.to_lowercase()})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.masters[0].theme.name, starter.theme, "{}", starter.name); + assert_eq!(st.doc.slides.len(), starter.slides.len(), "{}", starter.name); + assert_eq!(st.doc.slides[0].title(), starter.slides[0].title, "{}", starter.name); + assert!(!st.is_dirty(), "{}", starter.name); + assert!(st.history.undo.is_empty(), "{}", starter.name); + assert_eq!(st.selection.slide, 0); + } + let listed = s.execute("file.templates", &json!({})).unwrap(); + assert_eq!(listed["templates"].as_array().map(Vec::len), Some(starters().len())); + } + + #[test] + fn briefing_keeps_notes_columns_and_outline_levels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"template": "Briefing", "size": [800.0, 450.0]})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.slide_size.width, 800.0); + assert_eq!(doc.slides[0].notes_text(), "Open with what changed since last week."); + let (agenda, level) = body(doc, 1, 0); + assert!(agenda.contains("One concrete change"), "{agenda}"); + assert_eq!(level, 1); + let (done, _) = body(doc, 3, 0); + let (next, _) = body(doc, 3, 1); + assert!(done.starts_with("Done"), "{done}"); + assert!(next.starts_with("Next"), "{next}"); + } + + #[test] + fn pitch_comparison_fills_both_columns() { + let doc = build("Pitch", defaults::WIDE).unwrap(); + assert_eq!(body(&doc, 3, 0).0, "Today"); + assert!(body(&doc, 3, 1).0.contains("buried")); + assert_eq!(body(&doc, 3, 2).0, "After"); + assert!(body(&doc, 3, 3).0.contains("top")); + } + + #[test] + fn search_matches_name_summary_and_theme() { + assert_eq!(matching("ridge").iter().map(|t| t.name).collect::>(), vec!["Field Notes"]); + assert_eq!(matching("nocturne").iter().map(|t| t.name).collect::>(), vec!["Evening Talk"]); + assert_eq!(matching("").len(), starters().len()); + assert!(matching("facet").is_empty()); + } + + #[test] + fn unknown_blank_and_theme_are_refused() { + let mut s = Session::new(); + let unknown = s.execute("file.new", &json!({"template": "Ion"})).unwrap_err(); + assert!(unknown.to_string().contains("Briefing"), "{unknown}"); + let both = s.execute("file.new", &json!({"template": "Lesson", "theme": "Harbor"})).unwrap_err(); + assert!(both.to_string().contains("not both"), "{both}"); + let blank = s.execute("file.new", &json!({"template": "Lesson", "blank": true})).unwrap_err(); + assert!(blank.to_string().contains("blank"), "{blank}"); + assert!(s.active().is_none()); + } + + #[test] + fn a_starter_round_trips_through_pptx() { + let doc = build("Evening Talk", defaults::WIDE).unwrap(); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.slides.len(), doc.slides.len()); + for (before, after) in doc.slides.iter().zip(back.slides.iter()) { + assert_eq!(after.title(), before.title()); + } + assert!(back.slides[3].shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("bedroom")))); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..19da6b09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -25,6 +25,34 @@ fn new_presentation_and_slides() { assert_eq!(s.doc().unwrap().doc.slides.len(), 3); } +#[test] +fn text_transform_sets_and_clears_a_warp() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": "ARCH"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let body = |s: &mut Session| s.execute("shape.inspect", &json!({"id": id})).unwrap()["shape"]["text"]["body"].clone(); + assert!(s.execute("format.textWarp", &json!({})).is_err()); + assert!(s.execute("format.textWarp", &json!({"preset": "notAWarp"})).is_err()); + assert!(s.execute("format.textWarp", &json!({"preset": "textArchUp", "adj": "wide"})).is_err()); + assert!(s.execute("format.textWarp", &json!({"preset": "textArchUp", "adj": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]})).is_err()); + assert!(s.execute("format.textWarp", &json!({"preset": "textArchUp", "adj": [null]})).is_err()); + assert!(body(&mut s)["warp"].is_null()); + s.execute("format.textWarp", &json!({"preset": "textArchUp"})).unwrap(); + assert_eq!(body(&mut s)["warp"], "textArchUp"); + assert!(body(&mut s).get("warpAdj").is_none()); + s.execute("format.textWarp", &json!({"preset": "textInflate", "adj": [20_000.0]})).unwrap(); + let b = body(&mut s); + assert_eq!(b["warp"], "textInflate"); + assert_eq!(b["warpAdj"], json!([20_000.0])); + s.execute("format.textWarp", &json!({"preset": "none"})).unwrap(); + assert!(body(&mut s)["warp"].is_null()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(body(&mut s)["warp"], "textInflate"); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("format.textWarp", &json!({"preset": "textCircle"})).unwrap(); + assert_eq!(body(&mut s)["warp"], "textCircle"); +} + #[test] fn insert_shape_and_format() { let mut s = session(); @@ -66,6 +94,26 @@ fn text_editing_flow() { assert_eq!(s.doc().unwrap().selection.shapes.len(), 1); } +#[test] +fn editing_text_refits_a_saved_shrink() { + use deckcraft_model::text::AutoFit; + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id; + s.execute("text.edit", &json!({"id": title.0})).unwrap(); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + // As if opened from a file PowerPoint saved shrunk. + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).unwrap(); + let sh = deckcraft_model::find_shape_mut(shapes, title).unwrap(); + sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.5, line_reduction: 0.2 }); + Ok(()) + }) + .unwrap(); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + let sh = s.doc().unwrap().shape(title).cloned().unwrap(); + assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })); +} + #[test] fn smart_quotes_and_autocorrect() { let mut s = session(); @@ -93,6 +141,62 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +#[test] +fn bold_copy_is_html_and_pages_html_pastes_bold() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + let v = s.execute("edit.copy", &json!({})).unwrap(); + let html = v["html"].as_str().unwrap(); + assert!(html.contains(""), "{html}"); + assert_eq!(v["text"], "Hello"); + // The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed. + s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap(); + s.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap(); + assert_eq!(body.text(), "HelloHello"); + assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}"); + s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap(); + let caret = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(caret["text"], ""); + assert_eq!(caret["html"], ""); + + let mut ext = session(); + ext.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let pasted = ext + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello")) + .unwrap() + .text + .clone() + .unwrap(); + assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}"); + + let mut mismatched = session(); + mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "Nope"})).unwrap(); + let plain = mismatched + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .unwrap() + .text + .clone() + .unwrap(); + assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true)); +} + #[test] fn group_ungroup_roundtrip_positions() { let mut s = session(); @@ -126,7 +230,7 @@ fn align_and_distribute() { fn pointer_draw_move_resize() { let mut s = session(); s.set_tool(ToolKind::Shape { preset: "rect".into() }); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); @@ -157,7 +261,7 @@ fn pointer_draw_move_resize() { #[test] fn click_into_placeholder_starts_editing() { let mut s = session(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); @@ -181,6 +285,20 @@ fn sample_deck_builds_and_renders() { assert!(v["slides"].as_array().unwrap().len() >= 8); } +#[test] +fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { + use crate::cmd::file::writable_format_for_path as f; + assert_eq!(f("a.deckcraft"), Some("deckcraft")); + assert_eq!(f("dir/A.PPTX"), Some("pptx")); + assert_eq!(f("a.pdf"), Some("pdf")); + assert_eq!(f("a.jpg"), Some("jpeg")); + assert_eq!(f("a.txt"), Some("outline")); + assert_eq!(f("a.gif"), Some("gif")); + for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] { + assert_eq!(f(path), None, "{path}"); + } +} + #[test] fn save_and_reopen_native() { let mut s = Session::new(); @@ -226,6 +344,34 @@ fn base64_roundtrip() { assert!(cmd::base64_decode("!!").is_none()); } +#[test] +fn new_slide_body_tabs_match_text_set() { + let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck"; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap(); + let levels = |s: &Session| { + let slide = s.doc().unwrap().current_slide().unwrap(); + let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap(); + let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect(); + (sh.id.0, rows) + }; + let (id, from_new) = levels(&s); + assert_eq!( + from_new, + vec![ + (0, "Install from DMG".to_string()), + (0, "Launch app".to_string()), + (1, "Check signing".to_string()), + (2, "Deep".to_string()), + (0, "Build a deck".to_string()), + ] + ); + s.execute("text.set", &json!({"id": id, "text": body})).unwrap(); + assert_eq!(levels(&s).1, from_new); + s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap(); + assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]); +} + #[test] fn layout_change_keeps_text() { let mut s = session(); @@ -244,6 +390,11 @@ fn themes_and_slide_size() { assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, "Ember"); s.execute("design.slideSize", &json!({"preset": "standard"})).unwrap(); assert_eq!(s.doc().unwrap().doc.slide_size.width, 720.0); + s.execute("design.slideSize", &json!({"preset": "widescreen", "orientation": "portrait"})).unwrap(); + assert_eq!((s.doc().unwrap().doc.slide_size.width, s.doc().unwrap().doc.slide_size.height), (540.0, 960.0)); + s.execute("design.slideSize", &json!({"w": 540.0, "h": 960.0, "orientation": "landscape"})).unwrap(); + assert_eq!((s.doc().unwrap().doc.slide_size.width, s.doc().unwrap().doc.slide_size.height), (960.0, 540.0)); + assert!(s.execute("design.slideSize", &json!({"preset": "standard", "orientation": "sideways"})).is_err()); s.execute("design.headerFooter", &json!({"slideNumber": true, "footer": true, "footerText": "Hello"})).unwrap(); let v = s.execute("slide.inspect", &json!({})).unwrap(); assert!(v["shapes"].as_array().unwrap().iter().any(|x| x["placeholder"] == "sldNum")); @@ -290,7 +441,7 @@ fn drawing_a_line_between_shapes_glues_it() { let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() }; - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; // Right site of the ellipse (200,150) → left site of the rect (400,150), a little off. s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap(); s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap(); @@ -323,7 +474,7 @@ fn freeform_tools_and_command() { s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before + 1); - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; // Polygon: click, click, click, click on the start → closed, filled. s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { @@ -376,11 +527,27 @@ fn double_click_in_table_uses_grown_row_heights() { for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } +#[test] +fn double_click_edits_text_inside_group() { + // A text member of a group is still editable: double-click reaches it through the group + // (regression: it used to be a no-op, so grouped text boxes could not be edited). + let mut s = session(); + let a = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 120.0, 50.0], "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300.0, 200.0, 50.0, 50.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + // Double-click the grouped text box: it becomes editable and targets that member. + s.pointer(ev(PointerKind::DoubleClick, 160.0, 125.0)).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(a)); +} + #[test] fn grown_table_frame_covers_its_drawn_rows() { // Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows. @@ -401,11 +568,307 @@ fn grown_table_frame_covers_its_drawn_rows() { assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. }))); } +/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`. +fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId { + let rows = data.as_array().map_or(1, Vec::len); + let w: f64 = cols.iter().sum(); + let r = + s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + for (c, cw) in cols.iter().enumerate() { + s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap(); + } + s.execute("edit.deselect", &json!({})).unwrap(); + id +} + +fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table { + match &s.doc().unwrap().shape(id).unwrap().kind { + deckcraft_model::ShapeKind::Table(t) => t.clone(), + k => panic!("not a table: {k:?}"), + } +} + +fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None } +} + +fn text_sel(s: &Session) -> Option { + s.doc().unwrap().selection.text.clone() +} + +/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited. +fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point { + let st = s.doc().unwrap(); + text_point(st, st.selection.text.as_ref().unwrap(), para, ch) +} + +/// Slide point of the caret before character `ch` of paragraph `para` of text `t`. +fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point { + let (l, m) = cmd::text::text_frame(st, t).unwrap(); + let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap(); + m * deckcraft_geom::Point::new(x, (top + bot) / 2.0) +} + +#[test] +fn cell_text_is_laid_out_where_the_cell_draws_it() { + // The caret and hit testing of a cell's text use the cell's rect, not the table's. + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}"); + // An empty cell still has a caret. + s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}"); + // Hit testing: the start and the end of "Hello world". + let st = s.doc().unwrap(); + let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() }; + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0)); + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11)); +} + +#[test] +fn double_click_in_a_cell_selects_the_word_clicked() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + // Where "world" is drawn. + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // As the UI sends it: press, release, press, double-click (on release), then the release. + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + assert!(text_sel(&s).is_none(), "a click on an unselected table selects it"); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click"); + // The control channel sends the double-click alone. + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11)))); +} + +#[test] +fn clicks_while_editing_a_cell_move_the_caret_between_cells() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let p6 = caret_point(&s, 0, 6); + let p11 = caret_point(&s, 0, 11); + s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap(); + // A click in another cell of the table moves the caret there, at the click. + s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6))); + // Dragging selects within that cell, even past its edge. + s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11)))); + // Shift+click in another cell starts a new caret there instead of extending across cells. + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false)); + // Typing goes into the clicked cell. + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX"); + // A click outside the table stops editing. + s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap(); + assert!(text_sel(&s).is_none()); +} + +#[test] +fn a_click_in_a_selected_table_enters_the_clicked_cell() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]])); + s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap(); + assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table"); + let st = s.doc().unwrap(); + let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click"); + // Dragging a selected table still moves it. + s.execute("text.exit", &json!({})).unwrap(); + s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap(); + assert!(text_sel(&s).is_none()); + assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0); +} + +#[test] +fn caret_moves_by_line_in_a_wrapped_cell() { + let mut s = session(); + let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap(); + s.execute("text.move", &json!({"to": "down"})).unwrap(); + let down = text_sel(&s).unwrap().caret; + assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}"); + s.execute("text.move", &json!({"to": "lineEnd"})).unwrap(); + let end = text_sel(&s).unwrap().caret; + assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}"); + s.execute("text.move", &json!({"to": "up"})).unwrap(); + assert!(text_sel(&s).unwrap().caret.1 < down.1); +} + +#[test] +fn table_borders_are_hit_where_they_are_drawn() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0); + assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 })); + assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge"); + assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 })); + assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge"); + assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table"); + assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. }))); + // Hovering a border shows it (the UI picks the resize cursor from the hover). + s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 })); + // A border inside a merged cell isn't there. + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. }))); + assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 })); +} + +#[test] +fn dragging_column_borders_resizes_columns() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let undo_depth = s.doc().unwrap().history.undo.len(); + // An inner border: the two columns share the change, the table keeps its width. + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag"); + // Columns don't collapse. + s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]); + // The right edge: the last column and the table get wider. + s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0); + // A click on a border without dragging changes nothing. + let before = s.doc().unwrap().doc.clone(); + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); +} + +#[test] +fn dragging_a_row_border_sets_the_row_height() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]])); + let drawn = |s: &Session| { + let st = s.doc().unwrap(); + tools::table_rows(st, &table_of(s, id)) + }; + s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap(); + assert_eq!(drawn(&s), vec![70.0, 40.0]); + let st = s.doc().unwrap(); + assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows"); + // Rows still fit their text: dragging the border up stops at the text. + s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap(); + let h = drawn(&s)[0]; + assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}"); + // The bottom edge sizes the last row. + let bottom = 100.0 + drawn(&s).iter().sum::(); + s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap(); + assert_eq!(drawn(&s)[1], 60.0); +} + +#[test] +fn dragging_a_border_while_editing_a_cell_keeps_editing() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap(); + s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1))); +} + +#[test] +fn column_width_can_keep_the_table_width() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]])); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]); + // The last column has no neighbour: the table changes width. + s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0); +} + +#[test] +fn rotated_tables_hit_the_cell_under_the_pointer() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]])); + s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap(); + let st = s.doc().unwrap(); + let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap(); + let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1))); +} + +#[test] +fn tab_moves_between_cells_skipping_merged_ones() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let cell = |s: &Session| text_sel(s).and_then(|t| t.cell); + let shift = Mods { shift: true, ..Default::default() }; + s.key("Tab", Mods::default()).unwrap(); + assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped"); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0))); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays"); + s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap(); + s.key("Tab", Mods::default()).unwrap(); + assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row"); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); + // Explicitly left-to-right first, so typing Arabic doesn't already turn it right-to-left (#169). + s.execute("format.rtl", &json!({"on": false})).unwrap(); s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); @@ -426,6 +889,61 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + + let mut first_x = None; + let mut last_x = 0.0; + + // Type Kurdish sentence character-by-character: "من ناوم هێڤاره" + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + s.execute("text.insert", &json!({"text": ch})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().unwrap(); + let l = super::cmd::text::layout_for(st, t).unwrap(); + let (cx, ..) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }).unwrap(); + + if first_x.is_none() { + first_x = Some(cx); + } + last_x = cx; + + // The title is centred: the caret must NOT jump back to the left margin (~7.2 pt). + assert!(cx > 100.0, "caret should not jump to left margin: {cx}"); + } + + // Caret advances leftwards overall as text is appended + assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true), "auto-detected as RTL"); + assert_eq!(para.align, None, "the title keeps the alignment it inherits"); + + // A text box inherits the deck's default (left) alignment, which it keeps too. + s.execute("text.exit", &json!({})).ok(); + let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": ""})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let st = s.doc().unwrap(); + let sh = st.shape(deckcraft_model::ShapeId(id as u32)).unwrap(); + let para = sh.text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true)); + assert_eq!(para.align, None, "the text box keeps the alignment it inherits"); +} + +#[test] +fn explicit_ltr_override_is_preserved_when_typing_rtl() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("format.rtl", &json!({"on": false})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(false), "user explicit LTR is preserved"); +} + #[test] fn header_footer_fixed_date_is_the_slides_own_text() { use deckcraft_model::text::RunKind; @@ -504,6 +1022,22 @@ fn hyperlink_on_a_word_or_shape_links_it() { assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); } +#[test] +fn hyperlink_can_keep_highlight_click() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "https://example.org", "tooltip": "tip", "highlightClick": true})).unwrap(); + let click = s.doc().unwrap().current_slide().unwrap().shapes[0].click.clone().unwrap(); + assert_eq!(click.tooltip, "tip"); + assert!(click.highlight_click); + assert_eq!(click.action, model::text::Action::Url { url: "https://example.org".into() }); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + let click = s.doc().unwrap().current_slide().unwrap().shapes[0].click.clone().unwrap(); + assert!(!click.highlight_click, "omitting the flag still stores false"); +} + /// A .pptx (base64) whose first slide's title is set in `font`. fn deck_in_font(font: &str) -> String { let mut p = deckcraft_model::Presentation::default(); @@ -528,6 +1062,26 @@ fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() { assert!(s.doc().unwrap().missing_fonts.is_empty()); } +#[test] +fn an_embedded_face_is_used_when_the_machine_lacks_it() { + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some("DeckCraft Colr Squares".into()); + slide.shapes[0].text = Some(body); + let id = p + .add_media("font1.fntdata", "application/x-fontdata", include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) + .expect("media"); + p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "DeckCraft Colr Squares".into(), regular: Some(id), ..Default::default() }); + let data = crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap()); + let mut s = Session::with_new(); + let r = s.execute("file.openBytes", &json!({"name": "fonts.pptx", "data": data})).unwrap(); + assert_eq!(r["missingFonts"], json!([])); + let font = s.doc().unwrap().doc.embedded_fonts.first().unwrap(); + assert_eq!(font.typeface, "DeckCraft Colr Squares"); + assert!(font.regular.is_some()); +} + #[test] fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { // Flips don't mirror text, so a click lands on the character drawn under it. @@ -580,7 +1134,7 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { // Inserting off the slide keeps the box where it was asked for. assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); s.execute("edit.select", &json!({"ids": []})).unwrap(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // Hover and click wholly off the slide. assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); @@ -698,9 +1252,9 @@ fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); s.compose_text("녕", None).unwrap(); - s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); - s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); assert_eq!(title_text(&s), "안녕"); @@ -775,6 +1329,41 @@ fn ime_composition_underlines_follow_the_text() { assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); } +/// #101: a composition in a table cell is underlined inside that cell, the same way slide text is. +#[test] +fn ime_composition_in_a_table_cell_is_underlined() { + let mut s = session(); + let id = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [80.0, 90.0, 400.0, 120.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); + let ts = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(cmd::text::caret_line(s.doc().unwrap(), &ts).is_some(), "the caret is in the cell"); + s.compose_text("にほんご", Some((2, 4))).unwrap(); + let u = cmd::text::composition_underlines(s.doc().unwrap()); + let thin: Vec<_> = u.iter().filter(|x| !x.2).collect(); + let thick: Vec<_> = u.iter().filter(|x| x.2).collect(); + assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}"); + let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().clone(); + let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh); + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { panic!("not a table") }; + let col = tb.cols[0]; + let row = cmd::table_drawn_heights(&s.doc().unwrap().doc, &s.doc().unwrap().selection, tb)[0]; + for (a, b, _) in &u { + assert!(b.x > a.x && (a.y - b.y).abs() < 1e-6, "a horizontal line: {a:?} {b:?}"); + assert!(a.x >= x.x - 1.0 && b.x <= x.x + col + 1.0, "inside the first column"); + assert!(a.y > x.y && a.y < x.y + row + 1.0, "inside the first row"); + } + assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end"); + let ts = s.doc().unwrap().selection.text.clone().unwrap(); + let (top, bot) = cmd::text::caret_line(s.doc().unwrap(), &ts).unwrap(); + assert!(top.x >= x.x && top.x <= x.x + col && top.y >= x.y && bot.y <= x.y + row + 1.0, "caret in the cell"); + s.commit_text("日本語").unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); + let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().clone(); + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { panic!("not a table") }; + assert_eq!(tb.cell(0, 0).map(|c| c.text.text()), Some("日本語".into())); +} + /// File › Info (#76): the active presentation's name, location, counts and properties. #[test] fn file_info_reports_the_presentation() { @@ -835,3 +1424,2069 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn cell_fill_walks_only_the_cells_a_huge_selection_covers() { + // A selection may reach far past the table; filling it touches the real cells and nothing more. + let mut s = session(); + let id = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 200.0, 40.0]})).unwrap()["id"].clone(); + s.execute("table.selectCells", &json!({"id": id, "from": [1, 1], "to": [1_000_000_000u64, 1_000_000_000u64]})).unwrap(); + s.execute("table.cellFill", &json!({"none": true})).unwrap(); + let st = s.doc().unwrap(); + let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") }; + let fills: Vec = [(0, 0), (0, 1), (1, 0), (1, 1)].iter().map(|&(r, c)| t.cell(r, c).is_some_and(|x| x.fill.is_some())).collect(); + assert_eq!(fills, [false, false, false, true]); +} + +/// Each slide as (title, [(level, bullet text)]). +fn outline_of(p: &deckcraft_model::Presentation) -> Vec<(String, Vec<(u8, String)>)> { + p.slides + .iter() + .map(|s| { + let body = + s.shapes.get(1).and_then(|b| b.text.as_ref()).map(|t| t.paragraphs.iter().map(|p| (p.level, p.text())).collect()).unwrap_or_default(); + (s.title(), body) + }) + .collect() +} + +const MD_OUTLINE: &str = "# Intro\n- Point one\n- Point two\n - Sub point\n\n# Second\n- More\n"; + +fn md_expected() -> Vec<(String, Vec<(u8, String)>)> { + vec![ + ("Intro".into(), vec![(0, "Point one".into()), (0, "Point two".into()), (1, "Sub point".into())]), + ("Second".into(), vec![(0, "More".into())]), + ] +} + +#[test] +fn markdown_outlines_take_headings_as_slides_and_lists_as_bullets() { + let p = crate::cmd::file::open_presentation("outline.md", MD_OUTLINE.as_bytes()).unwrap(); + assert_eq!(outline_of(&p), md_expected()); + // numbered lists, `*` / `+` bullets, text under a heading, and closing #s + let p = crate::cmd::file::open_presentation("o.md", b"## Plan ##\n1. First\n2) Second\n* star\n+ plus\nA plain line\n").unwrap(); + assert_eq!( + outline_of(&p), + vec![("Plan".into(), vec![(0, "First".into()), (0, "Second".into()), (0, "star".into()), (0, "plus".into()), (0, "A plain line".into())])] + ); +} + +#[test] +fn slides_from_a_markdown_outline_file() { + let mut s = session(); + s.execute("slide.fromOutline", &json!({"text": MD_OUTLINE, "markdown": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(outline_of(doc)[1..], md_expected()[..]); +} + +#[test] +fn plain_outlines_are_unchanged() { + // .txt keeps the indentation format, and so does a .md without headings + let indented = "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore\n"; + let want = + vec![("Intro".to_string(), vec![(0, "Point one".to_string()), (1, "Sub point".to_string())]), ("Second".into(), vec![(0, "More".into())])]; + for name in ["outline.txt", "outline.md"] { + let p = crate::cmd::file::open_presentation(name, indented.as_bytes()).unwrap(); + assert_eq!(outline_of(&p), want, "{name}"); + } + let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap(); + assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro")); +} + +fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm { + let st = s.doc().unwrap(); + cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap()) +} + +fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value { + s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap() +} + +/// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide), +/// nothing selected. +fn two_shapes() -> (Session, deckcraft_model::ShapeId, deckcraft_model::ShapeId) { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0], "text": "Shape A"})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("insert.textBox", &json!({"rect": [400.0, 100.0, 200.0, 40.0], "text": "Box B"})).unwrap()["id"].as_u64().unwrap(); + s.select(|_, sel| { + sel.shapes.clear(); + sel.text = None; + }) + .unwrap(); + (s, deckcraft_model::ShapeId(a as u32), deckcraft_model::ShapeId(b as u32)) +} + +#[test] +fn right_click_selects_the_shape_under_the_pointer() { + let (mut s, a, b) = two_shapes(); + let undo_before = s.doc().unwrap().history.undo.len(); + // An unselected shape: selected alone, no text editing, no drag. + let r = right_click(&mut s, 150.0, 150.0); + assert_eq!(r["selected"], json!([a])); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes, vec![a]); + assert!(st.selection.text.is_none()); + assert!(!s.tool.dragging()); + s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0); + // A text box, which a left click would start editing: only selected. + right_click(&mut s, 450.0, 115.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes, vec![b]); + assert!(st.selection.text.is_none()); + // A multiple selection stays when the right click lands on one of its shapes. + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + right_click(&mut s, 450.0, 115.0); + assert_eq!(s.doc().unwrap().selection.shapes, vec![a, b]); + // Empty slide: nothing selected (the slide menu). + let r = right_click(&mut s, 700.0, 450.0); + assert!(r["hit"].is_null(), "{r}"); + assert!(s.doc().unwrap().selection.shapes.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +#[test] +fn right_click_in_edited_text_keeps_the_text_selection() { + let (mut s, a, b) = two_shapes(); + s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap(); + s.execute("text.move", &json!({"to": "wordRight", "extend": true})).unwrap(); + let before = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(before.is_range()); + // Anywhere in the box, even away from the selected word. + let r = right_click(&mut s, 280.0, 185.0); + assert_eq!(r["editing"], true); + assert_eq!(s.doc().unwrap().selection.text.as_ref(), Some(&before)); + // The context menu's Copy and Cut then act on the selected text. + assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().trim(), "Shape"); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(s.execute("text.get", &json!({"id": a})).unwrap()["text"].as_str().unwrap().trim(), "A"); + // A right click on another shape leaves editing and selects that shape. + right_click(&mut s, 450.0, 115.0); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![b]); +} + +mod copy_remap { + use std::sync::Arc; + + use deckcraft_model::style::PictureFill; + use deckcraft_model::{Animation, Shape, ShapeId, ShapeKind, Xfrm}; + use serde_json::json; + + use crate::*; + + /// Slide 0 of `s` gets a picture of `bytes`, a box, and a connector from the picture to the box. + /// Returns (picture, box, connector). + fn populate(s: &mut Session, bytes: &[u8]) -> (ShapeId, ShapeId, ShapeId) { + let st = s.doc_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let media = doc.add_media("p.png", "image/png", bytes.to_vec()).expect("media"); + let (p, b, c) = (ShapeId(doc.alloc_id().expect("id")), ShapeId(doc.alloc_id().expect("id")), ShapeId(doc.alloc_id().expect("id"))); + let x = Some(Xfrm::new(10.0, 10.0, 50.0, 50.0)); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(Shape { + id: p, + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + xfrm: x, + ..Default::default() + }); + slide.shapes.push(Shape { id: b, xfrm: x, ..Default::default() }); + slide.shapes.push(Shape { id: c, kind: ShapeKind::Connector { start: Some((p, 1)), end: Some((b, 3)) }, xfrm: x, ..Default::default() }); + (p, b, c) + } + + fn select(s: &mut Session, ids: &[ShapeId]) { + let sel = &mut s.doc_mut().unwrap().selection; + sel.slides.clear(); + sel.shapes = ids.to_vec(); + } + + fn shape(s: &Session, slide: usize, id: ShapeId) -> Shape { + let d = s.doc().unwrap(); + d.doc.slides[slide].shapes.iter().find(|x| x.id == id).cloned().unwrap() + } + + fn picture_bytes(s: &Session, sh: &Shape) -> Vec { + let ShapeKind::Picture { fill } = &sh.kind else { panic!("not a picture") }; + s.doc().unwrap().doc.media(fill.media).unwrap().data.as_ref().clone() + } + + #[test] + fn paste_into_a_deck_with_a_colliding_media_id_shows_the_copied_picture() { + let mut a = Session::with_new(); + let mut b = Session::with_new(); + let (pa, ..) = populate(&mut a, b"deck a picture"); + let (pb, ..) = populate(&mut b, b"deck b picture"); + assert_eq!(picture_bytes(&a, &shape(&a, 0, pa)), b"deck a picture"); + select(&mut a, &[pa]); + a.execute("edit.copy", &json!({})).unwrap(); + b.clipboard = a.clipboard.clone(); + b.doc_mut().unwrap().selection.shapes.clear(); + let r = b.execute("edit.paste", &json!({})).unwrap(); + let new = ShapeId(r["ids"][0].as_u64().unwrap() as u32); + assert_eq!(picture_bytes(&b, &shape(&b, 0, new)), b"deck a picture"); + // Deck b's own picture is untouched. + assert_eq!(picture_bytes(&b, &shape(&b, 0, pb)), b"deck b picture"); + } + + #[test] + fn copied_connectors_follow_copied_ends_and_detach_from_the_rest() { + let mut s = Session::with_new(); + let (p, b, c) = populate(&mut s, b"x"); + // Both ends copied: the copy connects the copies. + let r = s.execute("edit.duplicate", &json!({"ids": [p.0, b.0, c.0]})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| ShapeId(v.as_u64().unwrap() as u32)).collect(); + let ShapeKind::Connector { start, end } = shape(&s, 0, ids[2]).kind else { panic!() }; + assert_eq!((start, end), (Some((ids[0], 1)), Some((ids[1], 3)))); + // Only the connector copied: it doesn't stay glued to the originals. + select(&mut s, &[c]); + s.execute("edit.copy", &json!({})).unwrap(); + s.doc_mut().unwrap().selection.shapes.clear(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let only = ShapeId(r["ids"][0].as_u64().unwrap() as u32); + let ShapeKind::Connector { start, end } = shape(&s, 0, only).kind else { panic!() }; + assert_eq!((start, end), (None, None)); + // The original keeps its ends. + let ShapeKind::Connector { start, .. } = shape(&s, 0, c).kind else { panic!() }; + assert_eq!(start, Some((p, 1))); + } + + fn triggered(s: &mut Session, target: ShapeId, trigger: ShapeId) { + let st = s.doc_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + Arc::make_mut(&mut doc.slides[0]).animations.push(Animation { + shape: target, + trigger: Some(trigger), + effect: "fade".into(), + ..Default::default() + }); + } + + fn resolves(s: &Session, slide: usize) -> (ShapeId, ShapeId) { + let d = s.doc().unwrap(); + let sl = &d.doc.slides[slide]; + let a = &sl.animations[0]; + let t = a.trigger.unwrap(); + assert!(sl.shapes.iter().any(|x| x.id == a.shape) && sl.shapes.iter().any(|x| x.id == t), "{a:?}"); + (a.shape, t) + } + + #[test] + fn duplicated_and_pasted_slides_retarget_animations_and_triggers() { + let mut s = Session::with_new(); + let (p, b, _) = populate(&mut s, b"x"); + triggered(&mut s, p, b); + s.execute("slide.duplicate", &json!({"index": 0})).unwrap(); + let (t, tr) = resolves(&s, 1); + assert!(t != p && tr != b); + // Pasted slide. + s.execute("edit.copy", &json!({"scope": "slides"})).unwrap(); + s.execute("edit.paste", &json!({"scope": "slides"})).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let (t2, tr2) = resolves(&s, n - 1); + assert!(t2 != t && tr2 != tr); + // Save and reload keeps them resolving. + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + for sl in &back.slides { + for a in &sl.animations { + assert!(sl.shapes.iter().any(|x| x.id == a.shape)); + assert!(a.trigger.is_none_or(|t| sl.shapes.iter().any(|x| x.id == t))); + } + } + } +} + +#[test] +fn font_add_refuses_non_fonts_and_rescan_reports_the_catalog() { + let mut s = session(); + let not_font = crate::cmd::base64_encode(b"definitely not a font file"); + assert!(s.execute("font.add", &json!({"data": not_font})).is_err()); + assert!(s.execute("font.add", &json!({})).is_err()); + let r = s.execute("font.rescan", &json!({})).unwrap(); + assert!(r["families"].as_u64().unwrap() >= 1, "{r}"); +} + +/// The Print page's paper and orientation reach the PDF options; Letter portrait by default. +#[test] +fn pdf_options_take_paper_and_orientation() { + let o = cmd::file::pdf_options(&json!({}), 1).unwrap(); + assert_eq!(o.paper, (612.0, 792.0)); + let o = cmd::file::pdf_options(&json!({"paper": "A4", "orientation": "landscape"}), 1).unwrap(); + assert_eq!(o.paper, (841.89, 595.28)); + let o = cmd::file::pdf_options(&json!({"paper": "legal"}), 1).unwrap(); + assert_eq!(o.paper, (612.0, 1008.0)); + assert!(cmd::file::pdf_options(&json!({"paper": "tabloid"}), 1).is_err()); + assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); +} + +#[test] +fn shape_line_sets_compound_and_arrow_sizes() { + // The Format Shape pane's Line page: compound type, arrowheads and their sizes. + use deckcraft_model::style::Compound; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "line", "rect": [100, 100, 200, 0]})).unwrap(); + s.execute("shape.line", &json!({"compound": "thickThin", "head": "oval", "tail": "triangle", "tailSize": "lg"})).unwrap(); + let line = |s: &Session| s.doc().unwrap().selected_shapes()[0].line.clone().unwrap(); + let l = line(&s); + assert_eq!(l.compound, Some(Compound::ThickThin)); + assert_eq!(l.head.as_ref().map(|e| (e.kind.as_str(), e.w.as_str())), Some(("oval", "med"))); + assert_eq!(l.tail.as_ref().map(|e| (e.kind.as_str(), e.w.as_str(), e.len.as_str())), Some(("triangle", "lg", "lg"))); + // Changing the arrow keeps its size; an unknown size is ignored; "none" removes it. + s.execute("shape.line", &json!({"tail": "stealth", "headSize": "huge"})).unwrap(); + let l = line(&s); + assert_eq!(l.tail.as_ref().map(|e| (e.kind.as_str(), e.w.as_str())), Some(("stealth", "lg"))); + assert_eq!(l.head.as_ref().map(|e| e.w.as_str()), Some("med")); + s.execute("shape.line", &json!({"head": "none", "compound": "single"})).unwrap(); + let l = line(&s); + assert!(l.head.is_none()); + assert_eq!(l.compound, Some(Compound::Single)); +} + +#[test] +fn shape_effects_props_adjust_shadow_glow_and_reflection() { + // The Format Shape pane's Effects page: shadow, glow and reflection details. + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + // No shadow yet: the props add an outer one and set it. + s.execute("shape.effects", &json!({"shadowProps": {"blur": 10, "dist": 6, "dir": 405, "transparency": 0.25, "color": "accent2"}})).unwrap(); + let sw = fx(&s).outer_shadow.unwrap(); + assert_eq!((sw.blur, sw.dist, sw.dir), (10.0, 6.0, 45.0)); + assert!((sw.color.alpha() - 0.75).abs() < 1e-6); + // A new colour keeps the transparency; an inner shadow is adjusted in place. + s.execute("shape.effects", &json!({"shadowProps": {"color": "accent3"}})).unwrap(); + assert!((fx(&s).outer_shadow.unwrap().color.alpha() - 0.75).abs() < 1e-6); + s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap(); + s.execute("shape.effects", &json!({"shadowProps": {"blur": 3}})).unwrap(); + let f = fx(&s); + assert!(f.outer_shadow.is_none()); + assert_eq!(f.inner_shadow.unwrap().blur, 3.0); + // Glow: size, colour, transparency; size 0 removes it. + s.execute("shape.effects", &json!({"glowProps": {"radius": 12, "transparency": 0.5}})).unwrap(); + let g = fx(&s).glow.unwrap(); + assert_eq!(g.radius, 12.0); + assert!((g.color.alpha() - 0.5).abs() < 1e-6); + s.execute("shape.effects", &json!({"glowProps": {"radius": 0}})).unwrap(); + assert!(fx(&s).glow.is_none()); + // Reflection: transparency, size, distance, blur; hostile values are clamped or ignored. + s.execute("shape.effects", &json!({"reflectionProps": {"transparency": 0.3, "size": 2.0, "dist": 5, "blur": "x"}})).unwrap(); + let r = fx(&s).reflection.unwrap(); + assert!((r.start_alpha - 0.7).abs() < 1e-9); + assert_eq!((r.end_pos, r.dist, r.blur), (1.0, 5.0, 0.5)); +} + +/// Text Fill › Transparency (#32): the text keeps its colour, own or inherited, and gets an alpha; +/// 0 makes it opaque again, and it is one undo step. +#[test] +fn text_transparency_keeps_the_colour_and_sets_alpha() { + use deckcraft_model::{ColorTransform, Fill}; + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + let fill = |s: &Session| { + let st = s.doc().unwrap(); + st.shape(id).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.fill.clone() + }; + // Inherited colour: the run gets the colour it showed, now 25% transparent. + s.execute("format.textTransparency", &json!({"value": 0.25})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!("solid fill expected: {:?}", fill(&s)) }; + assert!((color.alpha() - 0.75).abs() < 1e-9, "{color:?}"); + // Its own colour stays; the alpha is replaced, not stacked. + s.execute("format.color", &json!({"color": "#C00000"})).unwrap(); + s.execute("format.textTransparency", &json!({"value": 0.6})).unwrap(); + s.execute("format.textTransparency", &json!({"value": 0.5})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert_eq!(color.base, deckcraft_model::ColorBase::Rgb { rgb: deckcraft_model::Rgba::rgb(0xC0, 0, 0) }); + assert_eq!(color.mods.iter().filter(|m| matches!(m, ColorTransform::Alpha(_))).count(), 1); + assert!((color.alpha() - 0.5).abs() < 1e-9); + s.execute("format.textTransparency", &json!({"value": 0})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert!(color.mods.is_empty(), "opaque drops the alpha: {color:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert!((color.alpha() - 0.5).abs() < 1e-9); + assert!(s.execute("format.textTransparency", &json!({})).is_err()); +} + +#[test] +fn edit_points_turns_a_preset_into_a_freeform_and_moves_adds_deletes() { + use deckcraft_model::Geom; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let r = s.execute("shape.editPoints", &json!({})).unwrap(); + let id = r["editing"].as_u64().unwrap(); + assert!(matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Custom { .. })); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; + let v = pts(&mut s); + assert_eq!(v.len(), 4, "{v:?}"); + assert!(near(v[0], [100.0, 100.0]) && near(v[2], [300.0, 200.0])); + // Drag the bottom-right corner out: the box grows to fit, other points stay on the slide. + s.execute("shape.movePoint", &json!({"index": 2, "x": 350, "y": 260})).unwrap(); + let v = pts(&mut s); + assert!(near(v[0], [100.0, 100.0]) && near(v[2], [350.0, 260.0]) && near(v[3], [100.0, 200.0]), "{v:?}"); + let x = s.doc().unwrap().selected_shapes()[0].xfrm.unwrap(); + assert!((x.w - 250.0).abs() < 1e-6 && (x.h - 160.0).abs() < 1e-6); + // Move the first point up-left: the box moves with it. + s.execute("shape.movePoint", &json!({"index": 0, "x": 80, "y": 90})).unwrap(); + let x = s.doc().unwrap().selected_shapes()[0].xfrm.unwrap(); + assert!((x.x - 80.0).abs() < 1e-6 && (x.y - 90.0).abs() < 1e-6); + assert!(near(pts(&mut s)[0], [80.0, 90.0])); + // Add a point in the middle of the closing edge (from the last point back to the first) and + // of the top edge, then delete one. + s.execute("shape.addPoint", &json!({"segment": 0})).unwrap(); + assert_eq!(pts(&mut s).len(), 5); + s.execute("shape.addPoint", &json!({"segment": 1, "t": 0.5})).unwrap(); + let v = pts(&mut s); + assert_eq!(v.len(), 6); + assert!(near(v[1], [(80.0 + 300.0) / 2.0, (90.0 + 100.0) / 2.0]), "{v:?}"); + s.execute("shape.deletePoint", &json!({"index": 1})).unwrap(); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + assert_eq!(pts(&mut s).len(), 4); + // Bad input is an error, not a panic; an outline keeps two points. + assert!(s.execute("shape.movePoint", &json!({"index": 99, "x": 1, "y": 1})).is_err()); + assert!(s.execute("shape.movePoint", &json!({"index": 0, "x": f64::NAN, "y": 1})).is_err()); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + assert!(s.execute("shape.deletePoint", &json!({"index": 0})).is_err()); + // Each edit is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pts(&mut s).len(), 3); + // It saves as custom geometry. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "e.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = s.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| sh.id.0 == id as u32).map(|sh| sh.geom.clone()); + assert!(matches!(back, Some(Geom::Custom { .. })), "{back:?}"); +} + +#[test] +fn edit_points_with_the_pointer_and_curves() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let v = pts(&mut s); + assert!(v.len() >= 4, "{v:?}"); + let ev = |kind, x: f64, y: f64, cmd: bool| PointerEvent { kind, x, y, mods: Mods { cmd, ..Default::default() }, ..Default::default() }; + // Drag the first vertex 30 pt left: one undo step. + let [x0, y0] = v[0]; + s.pointer(ev(PointerKind::Down, x0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Drag, x0 - 15.0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Drag, x0 - 30.0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Up, x0 - 30.0, y0, false)).unwrap(); + let w = pts(&mut s); + assert!((w[0][0] - (x0 - 30.0)).abs() < 1e-3 && (w[0][1] - y0).abs() < 1e-3, "{w:?}"); + assert!(w[1..].iter().zip(&v[1..]).all(|(a, b)| (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((pts(&mut s)[0][0] - x0).abs() < 1e-3); + // Ctrl-click a vertex deletes it. + let n = pts(&mut s).len(); + let [x1, y1] = pts(&mut s)[1]; + s.pointer(ev(PointerKind::Down, x1, y1, true)).unwrap(); + s.pointer(ev(PointerKind::Up, x1, y1, true)).unwrap(); + assert_eq!(pts(&mut s).len(), n - 1); + // Escape leaves Edit Points; then a click on empty slide doesn't touch the shape. + s.key("Escape", Mods::default()).unwrap(); + assert!(s.tool.edit_points.is_none()); +} + +#[test] +fn edit_points_path_parse_and_write() { + use crate::cmd::points::{Seg, parse, vertices, write}; + let segs = parse("M 0 0 L 10 0 C 10 5 5 10 0 10 Z junk L"); + // The closing edge is made explicit; its end is not a vertex of its own. + assert_eq!(segs.len(), 5); + assert!(matches!(segs[3], Seg::L(_))); + assert_eq!(vertices(&segs), vec![0, 1, 2]); + assert_eq!(write(&parse(&write(&segs))), write(&segs)); + assert!(parse("C 1 2 3").is_empty()); +} + +#[test] +fn edit_points_types_and_handles() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let q = |s: &mut Session| s.execute("shape.points", &json!({})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "corner"); + // A rectangle's corner has no handles until it is a smooth point. + let id = deckcraft_model::ShapeId(q(&mut s)["id"].as_u64().unwrap() as u32); + let handles = |s: &Session| { + let st = s.doc().unwrap(); + crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 1).unwrap() + }; + assert_eq!((handles(&s).0, handles(&s).1), (None, None)); + assert!(s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": 0, "y": 0})).is_err()); + s.execute("shape.pointType", &json!({"index": 1, "type": "smooth"})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "smooth"); + let (hi, ho, v) = handles(&s); + let (hi, ho) = (hi.unwrap(), ho.unwrap()); + assert!(((hi - v).hypot() - (ho - v).hypot()).abs() < 1e-6); + assert!(((hi - v).normalize() + (ho - v).normalize()).hypot() < 1e-6); + // Dragging one handle of a smooth point mirrors the other. + let to = v + deckcraft_geom::Vec2::new(40.0, 10.0); + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": to.x, "y": to.y})).unwrap(); + let (hi, ho, v) = handles(&s); + assert!((ho.unwrap() - to).hypot() < 1e-6 || q(&mut s)["types"][0][1] == "smooth"); + assert!(((hi.unwrap() - v) + (ho.unwrap() - v)).hypot() < 1e-6); + // Straight: in line, lengths kept; corner: independent. + s.execute("shape.pointType", &json!({"index": 1, "type": "straight"})).unwrap(); + let (_, ho, v) = handles(&s); + let far = v + (ho.unwrap() - v) * 3.0; + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": far.x, "y": far.y, "type": "straight"})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "straight"); + s.execute("shape.pointType", &json!({"index": 1, "type": "corner"})).unwrap(); + let (hi0, ho, v) = handles(&s); + let off = v + deckcraft_geom::Vec2::new(0.0, -30.0); + let _ = ho; + // (The pointer passes the type last chosen for the vertex, as here.) + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": off.x, "y": off.y, "type": "corner"})).unwrap(); + let (hi1, _, v1) = handles(&s); + assert!(((hi1.unwrap() - v1) - (hi0.unwrap() - v)).hypot() < 1e-6); + assert_eq!(q(&mut s)["types"][0][1], "corner"); + assert!(s.execute("shape.pointType", &json!({"index": 1, "type": "wobbly"})).is_err()); + // With the pointer: click a vertex to show its handles, drag one (one undo step). + s.execute("shape.pointType", &json!({"index": 2, "type": "smooth"})).unwrap(); + let st = s.doc().unwrap(); + let (_, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + let ho = ho.unwrap(); + let ev = |kind, x: f64, y: f64| PointerEvent { kind, x, y, mods: Mods::default(), ..Default::default() }; + s.pointer(ev(PointerKind::Down, v.x, v.y)).unwrap(); + s.pointer(ev(PointerKind::Up, v.x, v.y)).unwrap(); + assert_eq!(s.tool.edit_vertex, Some((0, 2))); + s.pointer(ev(PointerKind::Down, ho.x, ho.y)).unwrap(); + s.pointer(ev(PointerKind::Drag, ho.x + 20.0, ho.y + 20.0)).unwrap(); + s.pointer(ev(PointerKind::Up, ho.x + 20.0, ho.y + 20.0)).unwrap(); + let st = s.doc().unwrap(); + let (_, ho2, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + assert!((ho2.unwrap() - (ho + deckcraft_geom::Vec2::new(20.0, 20.0))).hypot() < 1e-3, "{ho2:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let (_, ho3, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + assert!((ho3.unwrap() - ho).hypot() < 1e-3); +} + +/// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)" +/// copy with a message, so Save can't overwrite the original with the empty media. +#[test] +fn damaged_native_file_opens_as_repaired_copy() { + let mut p = deckcraft_model::Presentation::default(); + p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()); + let good = deckcraft_format::save(&p).unwrap(); + // PNGs are stored as they are, so flipping a byte breaks the entry's CRC. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let dir = std::env::temp_dir().join(format!("deckcraft-392-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, bytes, damaged) in [("good.deckcraft", &good, false), ("bad.deckcraft", &bad, true)] { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + let mut s = Session::new(); + s.ui_requests.clear(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged)), "{r}"); + assert_eq!(st.path.is_none(), damaged, "{name}"); + assert_eq!(st.title.ends_with("(Repaired)"), damaged); + assert_eq!(s.ui_requests.iter().any(|u| matches!(u, crate::UiRequest::Message { .. })), damaged); + // Bytes route too. + let r = s.execute("file.openBytes", &json!({"name": name, "data": crate::cmd::base64_encode(bytes)})).unwrap(); + assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged))); + } + assert_eq!(std::fs::read(dir.join("bad.deckcraft")).unwrap(), bad, "original untouched"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn motion_path_presets_have_paths_ids_and_round_trip() { + use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path}; + let paths: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path).collect(); + // Preset ids identify the effect when PowerPoint's file is read back: no two share one. + let mut ids: Vec = paths.iter().map(|a| a.3).collect(); + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate motion-path preset ids"); + assert!(n >= 40, "{n} motion paths"); + let mut s = session(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap()["id"].clone(); + s.execute("animation.set", &json!({"ids": [id], "effect": "heart", "class": "path"})).unwrap(); + let a = s.doc().unwrap().doc.slides[s.doc().unwrap().selection.slide].animations[0].clone(); + assert_eq!(a.path, preset_path("heart")); + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + let b = back.slides.iter().flat_map(|sl| &sl.animations).next().unwrap(); + assert_eq!((b.effect.as_str(), b.class), ("heart", AnimClass::Path)); + assert!(b.path.as_deref().is_some_and(|p| p.contains('L'))); +} + +/// Bullets and Numbering dialog: numbering takes a colour and a size like bullets do, and sizes +/// stay within PowerPoint's 25–400 %. +#[test] +fn numbering_takes_color_and_size() { + let mut s = Session::with_new(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap(); + s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap(); + let para = |s: &Session| { + let st = s.doc().unwrap(); + let id = st.selection.shapes[0]; + st.shape(id).unwrap().text.as_ref().unwrap().paragraphs[0].props.clone() + }; + let pp = para(&s); + assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod")); + assert_eq!(pp.bullet_size, Some(1.5)); + assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap(); + assert_eq!(para(&s).bullet_size, Some(4.0)); +} + +#[test] +fn paste_special_keeps_source_look_picture_or_text() { + use deckcraft_model::style::{ColorBase, Fill}; + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Q3 plan"})).unwrap(); + s.execute("shape.fill", &json!({"color": "accent1"})).unwrap(); + let old_accent = { + let d = s.doc().unwrap(); + let sl = d.current_slide().unwrap(); + d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1) + }; + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("design.theme", &json!({"name": "Ember"})).unwrap(); + { + let d = s.doc().unwrap(); + let sl = d.current_slide().unwrap(); + assert_ne!(d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1), old_accent); + } + let fill_of = |s: &Session, id: &Value| { + let sid = deckcraft_model::ShapeId(id.as_u64().unwrap() as u32); + s.doc().unwrap().shape(sid).unwrap().fill.clone() + }; + + let dest = s.execute("edit.pasteSpecial", &json!({"as": "destinationTheme"})).unwrap(); + assert!(matches!(fill_of(&s, &dest["ids"][0]), Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. }))); + + let keep = s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap(); + match fill_of(&s, &keep["ids"][0]) { + Some(Fill::Solid { color }) => assert_eq!(color.base, ColorBase::Rgb { rgb: old_accent }), + f => panic!("{f:?}"), + } + // The clipboard itself still holds the theme-following shape. + assert!(matches!(&s.clipboard.shapes[0].fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. }))); + + let pic = s.execute("edit.pasteSpecial", &json!({"as": "picture"})).unwrap(); + let pid = deckcraft_model::ShapeId(pic["id"].as_u64().or_else(|| pic["ids"][0].as_u64()).unwrap() as u32); + let d = s.doc().unwrap(); + let sh = d.shape(pid).unwrap(); + let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("{:?}", sh.kind) }; + assert_eq!(d.doc.media(fill.media).unwrap().content_type, "image/png"); + let x = sh.xfrm.unwrap(); + assert!((x.w - 216.0).abs() < 1.0 && (x.h - 116.0).abs() < 1.0, "{x:?}"); + + let n = s.doc().unwrap().current_slide().unwrap().shapes.len(); + s.execute("edit.pasteSpecial", &json!({"as": "text"})).unwrap(); + let d = s.doc().unwrap(); + let last = d.current_slide().unwrap().shapes.last().unwrap(); + assert_eq!(d.current_slide().unwrap().shapes.len(), n + 1); + assert_eq!(last.text.as_ref().unwrap().text(), "Q3 plan"); + assert!(s.execute("edit.pasteSpecial", &json!({"as": "bogus"})).is_err()); +} + +/// Image export keeps the whole slide: the scale is lowered so the picture fits the renderer's +/// largest side (past it the renderer clips, and big exports came out cropped). +#[test] +fn image_export_scale_fits_the_largest_picture() { + let mut s = Session::with_new(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(crate::cmd::file::image_scale(&doc, 2.0), 2.0); + assert_eq!(crate::cmd::file::image_scale(&doc, f64::NAN), 1.0); + let max = f64::from(deckcraft_render::MAX_SIDE); + assert_eq!(crate::cmd::file::image_scale(&doc, 16.0), 16.0, "16× of 960 pt fits"); + s.execute("design.slideSize", &json!({"w": 4032, "h": 72})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let k = crate::cmd::file::image_scale(&doc, 4.0); + assert!(k * 4032.0 <= max + 1e-6 && k * 4032.0 > max - 1.0, "{k}"); +} + +/// Exporting every slide as images: when one file can't be written, the error says which +/// slides already were. +#[test] +fn a_failed_all_slides_export_says_what_was_written() { + let dir = std::env::temp_dir().join(format!("deckcraft-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("deck2.png")).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let e = s.execute("file.export", &json!({"path": dir.join("deck.png").to_string_lossy(), "all": true, "scale": 0.1})).unwrap_err().to_string(); + assert!(e.contains("slides 1–1 were written"), "{e}"); + assert!(dir.join("deck1.png").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo keeps a bounded amount of memory: deck-wide edits (each step holds a copy of every +/// slide) are dropped oldest first once the steps pass the byte budget, while ordinary small +/// edits still get the full step count. +#[test] +fn undo_history_is_capped_by_memory_as_well_as_steps() { + let mut s = Session::with_new(); + for _ in 0..40 { + s.execute("slide.new", &json!({})).unwrap(); + } + let one_copy = crate::undo_cost(&s.doc().unwrap().doc, &Presentation::default()); + assert!(one_copy > 0); + s.doc_mut().unwrap().history = History { limit: 500, byte_limit: one_copy * 10, ..Default::default() }; + for i in 0..30 { + // Resizing the slides rescales every shape on every slide: a new copy of each. + let w = if i % 2 == 0 { 720 } else { 960 }; + s.execute("design.slideSize", &json!({"w": w, "h": 540})).unwrap(); + } + let n = s.doc().unwrap().history.undo.len(); + assert!((5..=11).contains(&n), "{n} deck-wide steps kept"); + // Still undoable, newest first. + s.execute("edit.undo", &json!({})).unwrap(); + // Small edits: each keeps one slide's copy, so many fit. + let mut s = Session::with_new(); + s.doc_mut().unwrap().history.byte_limit = one_copy * 10; + for _ in 0..120 { + s.execute("slide.new", &json!({})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), 120); +} + +#[test] +fn picture_styles_replace_each_other_in_one_step() { + const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, + 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, + 0x82, + ]; + let mut s = session(); + let data = cmd::base64_encode(PNG); + let id = s.execute("insert.picture", &json!({"data": data, "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]})).unwrap()["id"].clone(); + let pic = |s: &Session| s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone(); + assert_eq!(s.execute("picture.styles", &json!({})).unwrap().as_array().unwrap().len(), 24); + + s.execute("picture.style", &json!({"ids": [id], "style": "reflectedRoundedRectangle"})).unwrap(); + let p = pic(&s); + assert_eq!(p.geom.preset_name(), Some("roundRect")); + assert!(p.effects.as_ref().unwrap().reflection.is_some()); + + // The next style replaces the whole look: no leftover reflection. + s.execute("picture.style", &json!({"ids": [id], "style": "simpleFrameWhite"})).unwrap(); + let p = pic(&s); + assert_eq!(p.geom.preset_name(), Some("rect")); + let e = p.effects.as_ref().unwrap(); + assert!(e.reflection.is_none() && e.outer_shadow.is_some()); + assert_eq!(p.line.as_ref().and_then(|l| l.width), Some(7.0)); + + s.execute("picture.style", &json!({"ids": [id], "style": "softEdgeOval"})).unwrap(); + let p = pic(&s); + assert_eq!(p.geom.preset_name(), Some("ellipse")); + assert_eq!(p.effects.as_ref().unwrap().soft_edge, Some(12.0)); + assert!(p.line.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None))); + + // Survives PPTX. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap(); + let bp = back.slides.iter().flat_map(|sl| &sl.shapes).find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. })).unwrap(); + assert_eq!(bp.geom.preset_name(), Some("ellipse")); + assert_eq!(bp.effects.as_ref().and_then(|e| e.soft_edge), Some(12.0)); + + // One undo step per style; none clears; unknown is refused. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pic(&s).geom.preset_name(), Some("rect")); + s.execute("picture.style", &json!({"ids": [id], "style": "none"})).unwrap(); + assert!(pic(&s).effects.is_none() && pic(&s).line.is_none()); + assert!(s.execute("picture.style", &json!({"ids": [id], "style": "sparkly"})).is_err()); +} + +#[test] +fn picture_recolor_and_transparent_color() { + const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, + 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, + 0x82, + ]; + let mut s = session(); + let id = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png"})).unwrap()["id"].clone(); + let adj = |s: &Session| match &s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().kind { + deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clone(), + _ => panic!("not a picture"), + }; + assert_eq!(s.execute("picture.recolors", &json!({})).unwrap().as_array().unwrap().len(), 21); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "bw50"})).unwrap(); + assert_eq!(adj(&s).bilevel, Some(0.5)); + // A new preset replaces the old one. + s.execute("picture.recolor", &json!({"ids": [id], "preset": "accent2Light"})).unwrap(); + let a = adj(&s); + assert!(a.bilevel.is_none()); + assert!(matches!(&a.duotone, Some((d, _)) if *d == deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "washout"})).unwrap(); + let a = adj(&s); + assert!(a.duotone.is_none() && a.brightness == 0.7 && a.contrast == -0.7); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "grayscale"})).unwrap(); + let a = adj(&s); + assert!(a.grayscale && a.brightness == 0.0, "washout undone by the next preset"); + assert!(s.execute("picture.recolor", &json!({"ids": [id], "preset": "plaid"})).is_err()); + + s.execute("picture.transparentColor", &json!({"ids": [id], "color": "#FFFFFF"})).unwrap(); + assert_eq!(adj(&s).clear_color, Some(deckcraft_color::Rgba::rgb(255, 255, 255))); + // Both survive PPTX. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap(); + let ba = back.slides.iter().flat_map(|sl| &sl.shapes).find_map(|sh| match &sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => Some(fill.adjust.clone()), + _ => None, + }); + let ba = ba.unwrap(); + assert!(ba.grayscale); + assert_eq!(ba.clear_color, Some(deckcraft_color::Rgba::rgb(255, 255, 255))); + s.execute("picture.transparentColor", &json!({"ids": [id], "none": true})).unwrap(); + assert!(adj(&s).clear_color.is_none()); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "none"})).unwrap(); + assert_eq!(adj(&s), deckcraft_model::style::PictureAdjust::default()); +} + +#[test] +fn select_all_slides_scope() { + let mut s = session(); + // One slide: selects it, no panic. + let r = s.execute("edit.selectAll", &json!({"scope": "slides"})).unwrap(); + assert_eq!(r["selected"].as_array().map(Vec::len), Some(1)); + for _ in 0..3 { + s.execute("slide.new", &json!({})).unwrap(); + } + s.execute("slide.hide", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).unwrap(); + let before = s.doc().unwrap().selection.slide; + s.execute("edit.selectAll", &json!({"scope": "slides"})).unwrap(); + let st = s.doc().unwrap(); + let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect(); + assert_eq!(st.selection.slides, all); + assert_eq!(st.selection.slide, before, "the current slide must not jump"); + assert!(st.selection.shapes.is_empty() && st.selection.text.is_none()); + assert_eq!(st.doc.slides.len(), 4); +} + +#[test] +fn select_all_slides_leaves_shape_select_all_alone() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + // No scope: still selects shapes on the current slide. + s.execute("edit.selectAll", &json!({})).unwrap(); + assert!(!s.doc().unwrap().selection.shapes.is_empty()); + assert!(s.doc().unwrap().selection.slides.len() <= 1); +} + +#[test] +fn select_all_slides_bad_scope_is_an_error() { + let mut s = session(); + assert!(s.execute("edit.selectAll", &json!({"scope": "everything"})).is_err()); + assert!(s.execute("edit.selectAll", &json!({"scope": 3})).is_err()); +} + +#[test] +fn table_borders_margins_and_cell_alignment() { + let mut s = session(); + let id = s.execute("insert.table", &json!({"rows": 3, "cols": 3, "rect": [100.0, 100.0, 300.0, 120.0]})).unwrap()["id"].clone(); + let table = |s: &Session| { + let st = s.doc().unwrap(); + let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone(); + let deckcraft_model::ShapeKind::Table(t) = sh.kind else { panic!("not a table") }; + t + }; + // Outside borders round the top-left 2×2 block; neighbours get the facing side too. + let block = json!([[0, 0], [0, 1], [1, 0], [1, 1]]); + s.execute("table.borders", &json!({"id": id, "which": "outside", "cells": block, "color": "#FF0000", "width": 2.0, "dash": "dash"})).unwrap(); + let t = table(&s); + let w = |r: usize, c: usize, side: usize| t.rows[r].cells[c].borders[side].as_ref().and_then(|l| l.width); + assert_eq!(w(0, 0, 0), Some(2.0)); + assert_eq!(w(0, 0, 2), Some(2.0)); + assert_eq!(w(0, 0, 1), None, "inside edge untouched"); + assert_eq!(w(1, 1, 1), Some(2.0)); + assert_eq!(w(1, 2, 0), Some(2.0), "neighbour's facing side"); + assert_eq!(w(2, 1, 2), Some(2.0), "neighbour below"); + assert!(matches!(&t.rows[0].cells[0].borders[0].as_ref().unwrap().dash, Some(deckcraft_model::style::Dash::Preset { name }) if name == "dash")); + // Inside horizontal only for the block. + s.execute("table.borders", &json!({"id": id, "which": "insideH", "cells": block})).unwrap(); + let t = table(&s); + assert!(t.rows[0].cells[0].borders[3].is_some() && t.rows[1].cells[0].borders[2].is_some()); + assert!(t.rows[0].cells[0].borders[1].is_none()); + // Diagonal and none. + s.execute("table.borders", &json!({"id": id, "which": "diagDown", "cells": [[2, 2]]})).unwrap(); + assert!(table(&s).rows[2].cells[2].diag_down.is_some()); + s.execute("table.borders", &json!({"id": id, "which": "none"})).unwrap(); + let t = table(&s); + assert!( + t.rows + .iter() + .flat_map(|r| &r.cells) + .all(|c| c.diag_down.is_none() && c.borders.iter().all(|b| b.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None)))) + ); + assert!(s.execute("table.borders", &json!({"id": id, "which": "sideways"})).is_err()); + // Margins and vertical alignment. + s.execute("table.cellMargins", &json!({"id": id, "preset": "wide", "cells": [[0, 0]]})).unwrap(); + s.execute("table.cellMargins", &json!({"id": id, "preset": "none", "left": 5.0, "cells": [[0, 1]]})).unwrap(); + s.execute("table.cellAlign", &json!({"id": id, "anchor": "middle"})).unwrap(); + let t = table(&s); + assert_eq!(t.rows[0].cells[0].margins, Some([14.4; 4])); + assert_eq!(t.rows[0].cells[1].margins, Some([5.0, 0.0, 0.0, 0.0])); + assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.anchor == Some(deckcraft_model::text::Anchor::Middle))); + // Survives PPTX. + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + let bt = back + .slides + .iter() + .flat_map(|sl| &sl.shapes) + .find_map(|sh| if let deckcraft_model::ShapeKind::Table(t) = &sh.kind { Some(t.clone()) } else { None }) + .unwrap(); + assert_eq!(bt.rows[0].cells[0].margins, Some([14.4; 4])); + assert_eq!(bt.rows[1].cells[1].anchor, Some(deckcraft_model::text::Anchor::Middle)); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(table(&s).rows[0].cells[0].anchor.is_none()); +} + +#[test] +fn smartart_text_pane_edits_regenerate_the_diagram() { + let mut s = session(); + let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "\tScope", "Build"]})).unwrap(); + let gid = r["id"].as_u64().unwrap(); + let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) { + Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(), + _ => panic!("not a group"), + }; + assert_eq!(kids(&s).len(), 2, "Scope is a bullet inside Plan"); + let box_before = s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm; + + // Type into the second shape directly, then add a node: the typed text is kept. + let build = kids(&s)[1].id.0; + s.execute("text.edit", &json!({"id": build, "end": true})).unwrap(); + s.execute("text.insert", &json!({"text": " it"})).unwrap(); + let r = s.execute("smartArt.addNode", &json!({"id": gid, "index": 2, "position": "after", "text": "Ship"})).unwrap(); + assert_eq!(r["index"], 3); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let texts: Vec<&str> = n["nodes"].as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); + assert_eq!(texts, ["Plan", "Scope", "Build it", "Ship"]); + assert_eq!(kids(&s).len(), 3); + + // Demote Ship under Build, promote Scope, remove Plan. + s.execute("smartArt.demote", &json!({"id": gid, "index": 3})).unwrap(); + assert_eq!(kids(&s).len(), 2); + s.execute("smartArt.promote", &json!({"id": gid, "index": 1})).unwrap(); + assert_eq!(kids(&s).len(), 3); + assert!(s.execute("smartArt.promote", &json!({"id": gid, "index": 0})).is_err()); + s.execute("smartArt.removeNode", &json!({"id": gid, "index": 0})).unwrap(); + let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap(); + let lv: Vec = n["nodes"].as_array().unwrap().iter().map(|x| x["level"].as_u64().unwrap()).collect(); + assert_eq!(lv, [0, 0, 1]); + + // A child shape also finds its diagram; layouts switch; the box stays put; undo works. + let child = kids(&s)[0].id.0; + s.execute("smartArt.layout", &json!({"id": child, "kind": "hierarchy"})).unwrap(); + assert_eq!(kids(&s).len(), 3 + 1, "three boxes and one link"); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm, box_before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kids(&s).len(), 2); + + // The outline survives a .deckcraft save. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let bytes = deckcraft_format::save(&doc).unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3))); +} + +#[test] +fn smartart_round_trips_through_pptx_as_a_diagram() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap(); + s.execute("smartArt.style", &json!({"style": "intense"})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": "accent3"})).unwrap(); + let doc = s.doc().unwrap().doc.as_ref().clone(); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let names: Vec = { + let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + z.file_names().map(String::from).collect() + }; + for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] { + assert!(names.iter().any(|n| n == &format!("ppt/diagrams/{part}.xml")), "{part} in {names:?}"); + } + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert!(back.validate().is_empty()); + let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group"); + let d = g.diagram.as_ref().unwrap(); + assert_eq!(d.kind, "hierarchy"); + assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "SmartArt style and colours come back"); + assert!(d.imported); + let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect(); + assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]); + assert!(g.children().len() >= 4, "the cached drawing is read back as shapes"); + + // The Text Pane works on the imported diagram, and a second round trip keeps the edit. + let gid = g.id.0; + let path = std::env::temp_dir().join(format!("deckcraft-smartart-{}.pptx", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let mut s2 = Session::new(); + s2.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + s2.execute("slide.go", &json!({"index": 1})).unwrap(); + s2.execute("smartArt.addNode", &json!({"id": gid, "index": 3, "position": "after", "text": "COO"})).unwrap(); + let again = deckcraft_pptx::import(&deckcraft_pptx::export(s2.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1))); +} + +#[test] +fn smartart_pictures_3d_styles_and_colour_variants() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "pictureCaption", "items": ["Lakes", "\tBlue", "Hills"]})).unwrap(); + let pics = |s: &Session| -> Vec { + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + g.children().iter().filter(|c| c.name == "SmartArt picture").cloned().collect() + }; + assert_eq!(pics(&s).len(), 2, "one picture placeholder per top-level node"); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + // A 1×1 PNG put in through a sub-point lands in its top-level node. + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + s.execute("smartArt.picture", &json!({"index": 1, "name": "p.png", "data": png})).unwrap(); + let p = pics(&s); + assert!(matches!(p[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert!(!matches!(p[1].fill, Some(deckcraft_model::Fill::Picture(_)))); + // Text edits keep the picture; reset removes it. + s.execute("smartArt.addNode", &json!({"text": "Woods"})).unwrap(); + assert!(matches!(pics(&s)[0].fill, Some(deckcraft_model::Fill::Picture(_)))); + assert_eq!(pics(&s).len(), 3); + // The picture is written to the .pptx drawing. + let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert!(back.validate().is_empty()); + let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).unwrap(); + assert_eq!(bd.kind, "pictureCaption"); + // The node's picture comes back from the data model's blip, as its media. + let m = bd.nodes[0].picture.expect("node picture read back"); + assert!(back.media(m).is_some_and(|x| !x.data.is_empty())); + assert!(bd.nodes.iter().skip(1).all(|n| n.picture.is_none())); + s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap(); + assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_))))); + + // 3-D styles carry a lit bevel; colour variants round trip through .pptx. + s.execute("smartArt.style", &json!({"style": "cartoon"})).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone(); + let raw = g.children().iter().find_map(|c| c.effects.as_ref().and_then(|e| e.raw3d.clone())).expect("3-D XML"); + assert!(raw.contains("scene3d") && raw.contains("softRound")); + for (style, colors) in [("polished", "range34"), ("powder", "accent5Grad"), ("brick", "accent2")] { + s.execute("smartArt.style", &json!({"style": style})).unwrap(); + s.execute("smartArt.colors", &json!({"colors": colors})).unwrap(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let d = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors)); + } +} + +#[test] +fn smartart_converts_to_and_from_text_and_shapes() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap(); + let before = s.doc().unwrap().doc.slides[1].shapes.len(); + let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.slides[1].shapes.len(), before, "the text box is replaced, not added to"); + let d = st.doc.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!(d.nodes.iter().map(|n| n.text.as_str()).collect::>(), ["Goals", "Revenue", "Growth"]); + + // Name and Title: titles show in their own box and survive a .pptx round trip. + s.execute("smartArt.layout", &json!({"id": gid, "kind": "nameTitleOrgChart"})).unwrap(); + s.execute("smartArt.setNodes", &json!({"id": gid, "index": 0, "title": "Lead"})).unwrap(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap(); + let bd = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap(); + assert_eq!((bd.kind.as_str(), bd.nodes[0].title.as_str()), ("nameTitleOrgChart", "Lead")); + + let tid = s.execute("smartArt.toText", &json!({"id": gid})).unwrap()["id"].as_u64().unwrap(); + let st = s.doc().unwrap(); + let tb = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == tid).unwrap(); + assert!(tb.diagram.is_none()); + assert_eq!(tb.text.as_ref().unwrap().paragraphs.len(), 3); + // The text keeps the size it had in the graphic. + assert!(tb.text.as_ref().unwrap().paragraphs.iter().all(|p| p.runs.iter().all(|r| r.props.size.is_some_and(|z| z > 10.0)))); + + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("smartArt.toShapes", &json!({"id": gid})).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == gid).unwrap(); + assert!(g.diagram.is_none() && !g.children().is_empty(), "a plain group of the same shapes"); +} + +/// Action Settings: click and hover actions on the selected shape, removed again with `none`. +#[test] +fn action_settings_set_click_and_hover() { + use deckcraft_model::text::Action; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + let shape = |s: &Session| s.doc().unwrap().selected_shapes()[0].clone(); + s.execute("shape.action", &json!({"action": "slide", "slide": 0, "highlight": false})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].id; + let sh = shape(&s); + assert!(matches!(sh.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::Slide { slide }, false)) if *slide == first)); + s.execute("shape.action", &json!({"on": "hover", "action": "url", "url": "https://example.com"})).unwrap(); + assert!(matches!(shape(&s).hover.map(|h| h.action), Some(Action::Url { url }) if url == "https://example.com")); + assert!(shape(&s).click.is_some(), "hover leaves the click action"); + s.execute("shape.action", &json!({"action": "none"})).unwrap(); + assert!(shape(&s).click.is_none()); + // Bad input is refused. + assert!(s.execute("shape.action", &json!({"action": "slide", "slide": 99})).is_err()); + assert!(s.execute("shape.action", &json!({"action": "url"})).is_err()); + assert!(s.execute("shape.action", &json!({"action": "customShow", "show": "nope"})).is_err()); + assert!(s.execute("shape.action", &json!({"on": "drag", "action": "next"})).is_err()); +} + +#[test] +fn artistic_effects_apply_change_remove_and_round_trip() { + use deckcraft_model::ShapeKind; + let mut img = image::RgbaImage::new(40, 30); + for (x, y, p) in img.enumerate_pixels_mut() { + *p = if x < 20 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([20, 20, 220, if y < 5 { 0 } else { 255 }]) }; + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap(); + let fill = |s: &Session| match &s.doc().unwrap().selected_shapes()[0].kind { + ShapeKind::Picture { fill } => fill.clone(), + k => panic!("{k:?}"), + }; + let original = fill(&s).media; + assert!(s.execute("picture.artisticEffect", &json!({"effect": "artisticBogus"})).is_err()); + // Every effect draws something of the same size and keeps transparency. + for (e, _) in cmd::artistic::EFFECTS { + s.execute("picture.artisticEffect", &json!({"effect": e})).unwrap(); + let f = fill(&s); + assert_eq!(f.artistic.as_ref().map(|a| (a.effect.as_str(), a.original)), Some((*e, original))); + assert_ne!(f.media, original); + let data = s.doc().unwrap().doc.media(f.media).unwrap().data.clone(); + let out = image::load_from_memory(&data).unwrap().to_rgba8(); + assert_eq!(out.dimensions(), (40, 30)); + assert_eq!(out.get_pixel(30, 1)[3], 0, "{e}"); + } + // A pencil sketch is grey. + s.execute("picture.artisticEffect", &json!({"effect": "artisticPencilGrayscale"})).unwrap(); + let data = s.doc().unwrap().doc.media(fill(&s).media).unwrap().data.clone(); + let out = image::load_from_memory(&data).unwrap().to_rgba8(); + assert!(out.pixels().all(|p| p[0] == p[1] && p[1] == p[2])); + // Saved and reopened, the effect and the original are both there. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = t.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Picture { fill } => Some(fill.clone()), + _ => None, + }); + let back = back.unwrap(); + let art = back.artistic.clone().unwrap(); + assert_eq!(art.effect, "artisticPencilGrayscale"); + assert_eq!(*t.doc().unwrap().doc.media(art.original).unwrap().data, png); + // None (and Reset Picture) puts the original back. + s.execute("picture.artisticEffect", &json!({"effect": "none"})).unwrap(); + assert_eq!((fill(&s).media, fill(&s).artistic), (original, None)); + s.execute("picture.artisticEffect", &json!({"effect": "artisticBlur"})).unwrap(); + s.execute("picture.reset", &json!({})).unwrap(); + assert_eq!((fill(&s).media, fill(&s).artistic), (original, None)); +} + +#[test] +fn handout_master_view_edits_and_round_trips() { + use deckcraft_model::PhType; + let mut s = Session::with_new(); + assert!(s.doc().unwrap().doc.handout_master.is_none()); + assert!(s.execute("handout.placeholders", &json!({"footer": false})).is_err(), "only in the view"); + let r = s.execute("view.handoutMaster", &json!({})).unwrap(); + assert_eq!(r["created"], true); + let st = s.doc().unwrap(); + assert_eq!(st.selection.target, Target::Handout); + assert_eq!(crate::page_size(st), st.doc.notes_size); + let kinds: Vec<_> = st.shapes().iter().filter_map(|x| x.ph_type()).collect(); + assert_eq!(kinds, [PhType::Header, PhType::Date, PhType::Footer, PhType::SlideNum]); + // Hide the footer, then show it again; undo restores the hidden state. + let r = s.execute("handout.placeholders", &json!({"footer": false})).unwrap(); + assert_eq!(r["footer"], false); + assert_eq!(s.doc().unwrap().shapes().len(), 3); + s.execute("handout.placeholders", &json!({"footer": true})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), 4); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), 3); + // Shapes drawn in the view land on the handout master, not the slide. + let slide_shapes = s.doc().unwrap().doc.slides[0].shapes.len(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 300, 100, 40]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.handout_master.as_ref().unwrap().shapes.len(), 4); + assert_eq!(s.doc().unwrap().doc.slides[0].shapes.len(), slide_shapes); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::Handout).is_some_and(|p| p.masters.len() == 1 && p.slide_size == p.notes_size)); + s.execute("view.closeMaster", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Slides); + // Reopening doesn't make a second master, and it survives PPTX. + assert_eq!(s.execute("view.handoutMaster", &json!({})).unwrap()["created"], false); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let hm = back.handout_master.expect("handout master kept"); + assert_eq!(hm.shapes.len(), 4); + assert!(hm.shapes.iter().any(|x| x.ph_type() == Some(PhType::SlideNum))); +} + +#[test] +fn notes_master_view_edits_and_saves() { + use deckcraft_model::PhType; + let mut s = Session::with_new(); + assert!(s.execute("notes.placeholders", &json!({"header": false})).is_err(), "only in the view"); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], true); + let st = s.doc().unwrap(); + assert_eq!(st.selection.target, Target::NotesMaster); + assert_eq!(crate::page_size(st), st.doc.notes_size); + let kinds: Vec<_> = st.shapes().iter().filter_map(|x| x.ph_type()).collect(); + assert_eq!(kinds, [PhType::Header, PhType::Date, PhType::SlideImage, PhType::Body, PhType::Footer, PhType::SlideNum]); + let r = s.execute("notes.placeholders", &json!({"header": false, "date": false})).unwrap(); + assert_eq!((r["header"].as_bool(), r["body"].as_bool()), (Some(false), Some(true))); + assert_eq!(s.doc().unwrap().shapes().len(), 4); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), 6); + s.execute("notes.placeholders", &json!({"header": false})).unwrap(); + // A shape added in the view is on the notes master and is saved, even with no notes in the deck. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [20, 20, 60, 60]})).unwrap(); + assert!(s.doc().unwrap().doc.slides.iter().all(|sl| sl.notes.is_empty())); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::NotesMaster).is_some()); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let nm = back.notes_master.expect("notes master written"); + assert_eq!(nm.shapes.len(), 6, "{:?}", nm.shapes.iter().map(|x| &x.name).collect::>()); + assert!(!nm.shapes.iter().any(|x| x.ph_type() == Some(PhType::Header))); + s.execute("view.closeMaster", &json!({})).unwrap(); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], false); +} + +/// Default picture export is 2 px per point. The file says 144 dpi, so Preview shows the slide's size. +#[test] +fn a_png_export_records_the_slide_resolution() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("deckcraft-dpi-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("slide.png"); + s.execute("file.export", &json!({"path": png.to_str().unwrap(), "format": "png"})).unwrap(); + let bytes = std::fs::read(&png).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&bytes), Some((144.0, 144.0))); + assert_eq!(png_size(&bytes), Some((1920, 1080)), "960×540 pt at 2 px per point"); + let one = dir.join("one.png"); + s.execute("file.export", &json!({"path": one.to_str().unwrap(), "format": "png", "scale": 1})).unwrap(); + let one_bytes = std::fs::read(&one).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&one_bytes), Some((72.0, 72.0))); + assert_eq!(png_size(&one_bytes), Some((960, 540))); + let jpg = dir.join("slide.jpg"); + s.execute("file.export", &json!({"path": jpg.to_str().unwrap(), "format": "jpeg"})).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&std::fs::read(&jpg).unwrap()), Some((144.0, 144.0))); + let _ = std::fs::remove_dir_all(&dir); +} + +fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?; + let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?; + Some((u32::from_be_bytes(w), u32::from_be_bytes(h))) +} + +/// A blank slide holding one text box being edited, with smart guides and grid snapping off so +/// drags move by exactly the pointer delta. Returns the session, the box id and its box. +fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap(); + if rot != 0.0 { + s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap(); + } + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let x = text_box_xfrm(&s, id); + (s, id, x) +} + +/// Slide point for a point in the box's own (rotated) frame. +fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) { + let p = x.affine() * deckcraft_geom::Point::new(lx, ly); + (p.x, p.y) +} + +fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + let down = s.pointer(ev(PointerKind::Down, from)).unwrap(); + let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0); + s.pointer(ev(PointerKind::Drag, mid)).unwrap(); + s.pointer(ev(PointerKind::Drag, to)).unwrap(); + s.pointer(ev(PointerKind::Up, to)).unwrap(); + down +} + +#[test] +fn dragging_the_frame_of_edited_text_moves_the_box() { + let (mut s, id, x) = editing_text_box(0.0); + assert!(x.h > 12.0, "{x:?}"); + let sid = deckcraft_model::ShapeId(id as u32); + // Hovering the band on either side of the edge says "move"; the inside says "text". + for ly in [-2.0, 2.0, x.h + 2.0] { + let (px, py) = on_box(&x, x.w / 4.0, ly); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}"); + } + let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true })); + + // Press on the top edge, just outside, and drag: the box moves, text editing ends. + let undo_before = s.doc().unwrap().history.undo.len(); + let from = on_box(&x, x.w / 4.0, -1.0); + let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0)); + assert_eq!(down["hit"], json!({"kind": "frame", "id": id})); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!((moved.w, moved.h), (x.w, x.h)); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + // One undo step, which puts the box back. + assert_eq!(st.history.undo.len(), undo_before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_box_xfrm(&s, id), x); + + // The inner side of the band works too; a click there without a drag selects the box whole + // and changes nothing. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0); + let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }; + s.pointer(ev(PointerKind::Down)).unwrap(); + s.pointer(ev(PointerKind::Up)).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + assert_eq!(st.history.undo.len(), undo_before); + assert_eq!(text_box_xfrm(&s, id), x); +} + +#[test] +fn dragging_inside_edited_text_selects_text() { + let (mut s, id, x) = editing_text_box(0.0); + let undo_before = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0)); + assert!(down.get("caret").is_some(), "{down}"); + assert_eq!(text_box_xfrm(&s, id), x); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().expect("still editing"); + assert!(t.is_range(), "{t:?}"); + assert_eq!(t.ordered(), ((0, 0), (0, 11))); + assert_eq!(st.history.undo.len(), undo_before); +} + +#[test] +fn frame_of_rotated_edited_text_follows_the_rotation() { + let (mut s, id, x) = editing_text_box(30.0); + assert_eq!(x.rot, 30.0); + let sid = deckcraft_model::ShapeId(id as u32); + // The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation. + let from = on_box(&x, x.w / 4.0, x.h + 1.0); + assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid })); + let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0)); + assert_eq!(down["hit"]["kind"], "frame"); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!(moved.rot, 30.0); + // Inside the rotated box, pressing still places the caret. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let mx = text_box_xfrm(&s, id); + let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert!(r.get("caret").is_some(), "{r}"); + assert_eq!(text_box_xfrm(&s, id), mx); + // Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press + // there leaves editing instead of moving the caret. + let off = on_box(&mx, mx.w / 2.0, -mx.h); + let off_pt = deckcraft_geom::Point::new(off.0, off.1); + assert!(mx.bounds().contains(off_pt)); + assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid })); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert!(r.get("caret").is_none(), "{r}"); + assert!(s.doc().unwrap().selection.text.is_none()); + assert_eq!(text_box_xfrm(&s, id), mx); +} + +#[test] +fn resize_handles_beat_the_frame_of_edited_text() { + let (mut s, id, x) = editing_text_box(0.0); + // The SE corner lies in the frame band, but its handle wins. + let from = on_box(&x, x.w, x.h); + let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1)); + assert_eq!(down["hit"]["kind"], "resize"); + let sized = text_box_xfrm(&s, id); + assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}"); + assert_eq!((sized.x, sized.y), (x.x, x.y)); +} + +#[test] +fn format_language_marks_the_selected_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola mundo"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + s.execute("format.language", &json!({"lang": "es-ES"})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES"); + let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect(); + assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]); +} + +#[test] +fn every_params_doc_names_its_keys() { + for c in command_specs() { + assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params)); + } + assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]); + assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]); +} + +#[test] +fn a_params_doc_spells_out_each_parameter() { + use cmd::Param; + let p = |name, optional, about, or, object| Param { name, optional, about, or, object }; + assert_eq!( + cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(), + [ + p("preset", false, "rect|roundRect", false, 0), + p("rect", true, "[x,y,w,h] pt", false, 0), + p("index", true, "(else selected shapes)", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(), + [ + p("color", true, "", false, 0), + p("none", true, "true", true, 0), + p("gradient", true, "{stops, kind?: a|b}", false, 0), + p("ids", true, "", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{name} | {major, minor}").unwrap(), + [p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)] + ); +} + +#[test] +fn what_a_command_acts_on_names_only_keys_it_takes() { + for c in command_specs() { + let keys = cmd::param_keys(c.params).unwrap(); + let said = c.acts_on().unwrap_or_default(); + assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id); + assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id); + } + let acts = |id: &str| find_command(id).and_then(|c| c.acts_on()); + assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes.")); + assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes.")); + assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes.")); + assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes.")); + assert_eq!(acts("slide.new"), None); +} + +/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the +/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land +/// in the cell where it is drawn, and borders still resize. +#[test] +fn tables_with_a_file_defined_style_edit_and_resize() { + use deckcraft_model::style::{ColorRef, Fill}; + use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef}; + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let built_in = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + // The table's style, defined by the file: a plain (not bold) header, a filled body, a + // background. + { + let style = table_of(&s, id).style; + let d = s.doc_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut d.doc); + let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)))); + doc.table_styles.push(TableStyleDef { + id: style, + name: "File style".into(), + parts: vec![ + (TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }), + (TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }), + ], + background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }), + raw: String::new(), + }); + } + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // A double-click selects the word drawn there. + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11)))); + // A click in another cell moves the caret there. + s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap(); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); + // Border drag, while editing: the columns resize, editing goes on. + s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); +} + +#[test] +fn editing_notes_keeps_their_formatting_through_pptx() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + // A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint. + let mut p = deckcraft_model::defaults::new_presentation(None); + let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("First point"), + point("Second point"), + ], + ..Default::default() + }; + let bytes = deckcraft_pptx::export(&p).unwrap(); + let mut s = session(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap(); + // Type a word into the second bullet, as the notes pane does. + s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap(); + let check = |notes: &TextBody| { + assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point"); + assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true)); + for para in ¬es.paragraphs[1..] { + assert_eq!(para.level, 1); + assert_eq!(para.props.margin_left, Some(22.5)); + assert_eq!(para.props.indent, Some(-22.5)); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() })); + } + }; + check(&s.doc().unwrap().doc.slides[0].notes); + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap(); + check(&reopened.slides[0].notes); + // slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are. + let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone(); + s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain"); + s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap(); + check(&s.doc().unwrap().doc.slides[0].notes); + assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err()); +} + +#[test] +fn autocorrect_options_as_you_type() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + let typed = |s: &mut Session, text: &str| -> String { + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.delete", &json!({"dir": "backward"})).ok(); + for c in text.chars() { + s.execute("text.insert", &json!({"text": c.to_string()})).unwrap(); + } + s.execute("text.get", &json!({"id": title})).unwrap()["text"].as_str().unwrap().to_string() + }; + assert_eq!(typed(&mut s, "hello world. see you monday, i think. "), "Hello world. See you Monday, I think. "); + assert_eq!(typed(&mut s, "THe tHIS 1/2 recieved... "), "The This ½ received… "); + // Options turn rules off. + s.execute("text.autocorrect", &json!({"sentenceCaps": false, "days": false})).unwrap(); + assert_eq!(typed(&mut s, "hello monday "), "hello monday "); + // Your own entries, and deleting a built-in one. + s.execute("text.autocorrect", &json!({"add": ["dc", "DeckCraft"], "remove": "teh"})).unwrap(); + assert_eq!(typed(&mut s, "dc teh "), "DeckCraft teh "); + let v = s.execute("text.autocorrect", &json!({})).unwrap(); + let entries = v["entries"].as_array().unwrap(); + assert!(entries.iter().any(|e| e[0] == "dc")); + assert!(!entries.iter().any(|e| e[0] == "teh")); + assert!(s.execute("text.autocorrect", &json!({"add": ["two words", "x"]})).is_err()); + // Off entirely. + s.execute("text.autocorrect", &json!({"on": false})).unwrap(); + assert_eq!(typed(&mut s, "dc adn "), "dc adn "); + // Pasting a whole string applies only the replacement list, not the case rules. + s.execute("text.autocorrect", &json!({"on": true, "sentenceCaps": true})).unwrap(); + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.insert", &json!({"text": "monday notes "})).unwrap(); + assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes "); +} + +#[test] +fn theme_variants_apply_and_report_the_current_one() { + let mut s = session(); + let name = deckcraft_model::theme::builtin_themes()[1].name.clone(); + s.execute("design.theme", &json!({"name": name})).unwrap(); + let v = s.execute("design.variants", &json!({})).unwrap(); + assert_eq!(v["variants"].as_array().unwrap().len(), 4); + assert_eq!(v["current"], json!(0)); + let base = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + s.execute("design.variant", &json!({"index": 1})).unwrap(); + let c = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + assert_eq!(c.colors[4], base.colors[5], "accents rotate"); + assert_eq!(c.colors[0], base.colors[0]); + assert_eq!(s.execute("design.variants", &json!({})).unwrap()["current"], json!(1)); + // From a variant, another variant is built from the theme's own colours, not the variant's. + s.execute("design.variant", &json!({"index": 3})).unwrap(); + let d = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + assert_eq!((d.colors[0], d.colors[1], d.colors[4]), (base.colors[1], base.colors[0], base.colors[4])); + s.execute("design.variant", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.colors.colors, base.colors); + assert!(s.execute("design.variant", &json!({"index": 9})).is_err()); + // It survives PPTX. + s.execute("design.variant", &json!({"index": 2})).unwrap(); + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + assert_eq!(back.masters[0].theme.colors.colors, p.masters[0].theme.colors.colors); +} + +#[test] +fn spelling_uses_a_dictionary_suggests_and_changes() { + crate::spell::install("xx-TEST", crate::spell::Dictionary::parse(crate::spell::tests::AFF.as_bytes(), crate::spell::tests::DIC.as_bytes())); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "Making sldie photos, sldie"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let v = s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap(); + let list = v.as_array().unwrap(); + assert_eq!(list.len(), 2, "{v}"); + assert_eq!(list[0]["word"], "sldie"); + assert_eq!(list[0]["suggestions"][0], "slide"); + assert!(s.execute("review.spelling", &json!({"lang": "xx-TEST", "ignore": ["SLDIE"]})).unwrap().as_array().unwrap().is_empty()); + let c = s.execute("review.spellingChange", &json!({"shape": id, "word": "sldie", "to": "slide"})).unwrap(); + assert_eq!(c["changed"], 1); + assert_eq!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().len(), 1); + let c = s.execute("review.spellingChange", &json!({"word": "sldie", "to": "slide", "all": true})).unwrap(); + assert_eq!(c["changed"], 1); + assert!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().is_empty()); + // Whole words only, and one undo step per change. + assert_eq!(s.execute("review.spellingChange", &json!({"word": "slid", "to": "x", "all": true})).unwrap()["changed"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.spelling", &json!({"lang": "xx-TEST"})).unwrap().as_array().unwrap().len(), 1); +} + +#[test] +fn spelling_without_a_dictionary_falls_back_to_common_misspellings() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "Teh qzxv"})).unwrap(); + let v = s.execute("review.spelling", &json!({"lang": "zz-NONE"})).unwrap(); + assert_eq!(v.as_array().map(Vec::len), Some(1)); + assert_eq!(v[0]["word"], "Teh"); + let d = s.execute("review.dictionary", &json!({"lang": "zz-NONE"})).unwrap(); + assert!(d["found"].is_null()); + assert_eq!(d["available"], false); +} + +#[test] +fn spelling_checks_each_run_in_its_language_and_learns_words() { + let dir = std::env::temp_dir().join(format!("deckcraft-userdic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + crate::spell::set_user_dictionary_path(dir.join("user.dic")); + crate::spell::install("xx-ONE", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n")); + crate::spell::install("xx-TWO", crate::spell::Dictionary::parse(b"", b"2\nhallo\nwelt\n")); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello hallo deckcraft"})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + // The second word is in another language (as a PPTX run's `lang` gives it). + s.edit(|doc, _| { + let sh = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[0]).shapes, id).unwrap(); + let p = &mut sh.text.as_mut().unwrap().paragraphs[0]; + let t = p.runs[0].text.clone(); + let props = p.runs[0].props.clone(); + p.runs = vec![ + deckcraft_model::Run::with(&t[..6], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props.clone() }), + deckcraft_model::Run::with(&t[6..12], deckcraft_model::RunProps { lang: Some("xx-TWO".into()), ..props.clone() }), + deckcraft_model::Run::with(&t[12..], deckcraft_model::RunProps { lang: Some("xx-ONE".into()), ..props }), + ]; + Ok(()) + }) + .unwrap(); + let v = s.execute("review.spelling", &json!({})).unwrap(); + let words: Vec<_> = v + .as_array() + .unwrap() + .iter() + .map(|w| (w["word"].as_str().unwrap().to_string(), w["lang"].as_str().unwrap().to_string(), w["start"].as_u64().unwrap())) + .collect(); + assert_eq!(words, vec![("deckcraft".to_string(), "xx-ONE".to_string(), 12)]); + s.execute("review.addToDictionary", &json!({"word": "deckcraft"})).unwrap(); + assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(std::fs::read_to_string(dir.join("user.dic")).unwrap(), "deckcraft\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn language_marks_text_and_no_proof_and_repeats_are_reported() { + crate::spell::install("xx-LN", crate::spell::Dictionary::parse(b"", b"3\nthe\nend\nis\n")); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "the the end is nigh"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("format.language", &json!({"lang": "xx-LN"})).unwrap(); + let v = s.execute("review.spelling", &json!({})).unwrap(); + let got: Vec<(String, bool)> = v.as_array().unwrap().iter().map(|w| (w["word"].as_str().unwrap().to_string(), w["repeated"] == true)).collect(); + assert_eq!(got, vec![("the".to_string(), true), ("nigh".to_string(), false)]); + assert_eq!(v[0]["deleteFrom"], 3); + // Deleting the repeat through the exact-occurrence change. + s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": 3, "word": " the", "to": ""})).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["shape"]["text"]["paragraphs"][0]["runs"][0]["text"], "the end is nigh"); + s.execute("format.language", &json!({"noProof": true})).unwrap(); + assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty()); + assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err()); +} + +#[test] +fn the_caret_takes_the_colour_of_the_text() { + let caret = |theme: &str| { + let mut s = session(); + s.execute("design.theme", &json!({"name": theme})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + let st = s.doc().unwrap(); + let empty = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap(); + s.execute("text.insert", &json!({"text": "Typed"})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(cmd::text::caret_color(st, &st.selection.text.clone().unwrap()), Some(empty)); + empty + }; + // Dark text on the light theme, light text on the dark one: never black on a dark slide. + let (light, dark) = (caret("DeckCraft"), caret("Nocturne")); + assert!(light.r < 0x60 && light.g < 0x60 && light.b < 0x60, "{light:?}"); + assert!(dark.r > 0xC0 && dark.g > 0xC0 && dark.b > 0xC0, "{dark:?}"); + // Text set to red gets a red caret. + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Red"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.color", &json!({"color": "#FF0000"})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + let st = s.doc().unwrap(); + let red = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((red.r, red.g, red.b), (0xFF, 0, 0)); +} + +/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX. +#[test] +fn smooth_corners_makes_a_squircle_outline() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("shape.smoothCorners", &json!({"smoothing": 60})).unwrap(); + let d = |s: &Session| match &s.doc().unwrap().shape(id).unwrap().geom { + deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(), + g => panic!("{g:?}"), + }; + // The default roundRect radius is 16.667% of the short side: 16.667 pt, smoothed 60%. + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // The smoothed outline takes a new smoothing with the radius it was drawn with; without a + // radius, or with another one (it could be any freeform), it is refused. + s.execute("shape.smoothCorners", &json!({"smoothing": 100, "radius": 16.667})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 1.0)); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 30})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // Other shapes are refused. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [0, 0, 50, 50]})).unwrap(); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + // So is a freeform, whatever radius is given. + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + s.execute("shape.editPoints", &json!({"on": false})).unwrap(); + assert!(matches!(s.doc().unwrap().shapes().last().unwrap().geom, deckcraft_model::Geom::Custom { .. }), "Edit Points made a freeform"); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 8})).is_err()); + // The outline round-trips through PPTX as custom geometry. + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + let back = crate::cmd::file::open_presentation("x.pptx", &bytes).unwrap(); + let sh = back.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. })); + assert!(sh.is_some(), "a custom outline in the saved file"); +} + +#[test] +fn wordart_adjust_handle_drags_and_undoes() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap(); + let sid = model::ShapeId(id as u32); + { + let st = s.doc_mut().unwrap(); + let slide = st.selection.slide; + let mut doc = (*st.doc).clone(); + let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap(); + sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into()); + st.doc = std::sync::Arc::new(doc); + } + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let (at, top) = { + let st = s.doc().unwrap(); + let sh = st.shape(sid).unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, sh); + let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h); + assert_eq!(hs.len(), 1); + let at = x.affine() * hs[0].pos; + (at, deckcraft_geom::Point::new(x.center().x, x.y)) + }; + assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. }))); + let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0, pressure: None }; + s.pointer(ev(PointerKind::Down, at)).unwrap(); + s.pointer(ev(PointerKind::Drag, top)).unwrap(); + s.pointer(ev(PointerKind::Up, top)).unwrap(); + let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone(); + let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0; + assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body; + assert_eq!(body.warp.as_deref(), Some("textArchUp")); + assert!(body.warp_adj.is_empty(), "undo restores the default adjusts"); +} + +#[test] +fn shape_picture_fill_options_tile_offset_and_round_trip() { + use deckcraft_model::style::PictureMode; + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() { + Some(deckcraft_model::Fill::Picture(pf)) => pf, + other => panic!("{other:?}"), + }; + // Options without a picture fill are a no-op, not an error. + s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap(); + s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap(); + assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] }); + s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap(); + let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() }; + let got = pf(&s); + assert_eq!(got.mode, want); + assert!(got.upright); + assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9); + // PPTX round trip keeps the options. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill { + Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()), + _ => None, + }); + let back = back.unwrap(); + assert_eq!(back.mode, want); + assert!(back.upright); + assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); +} + +#[test] +fn find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} + +#[test] +fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x, y, pressure| tools::PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure }; + s.pointer(ev(PointerKind::Down, 40.0, 80.0, Some(0.2))).unwrap(); + s.pointer(ev(PointerKind::Drag, 80.0, 80.0, Some(1.0))).unwrap(); + s.pointer(ev(PointerKind::Drag, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Up, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Down, 40.0, 160.0, None)).unwrap(); + s.pointer(ev(PointerKind::Drag, 100.0, 160.0, Some(f32::NAN))).unwrap(); + s.pointer(ev(PointerKind::Up, 100.0, 160.0, None)).unwrap(); + let got = ink_pressures(&s); + assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); +} + +fn ink_pressures(s: &Session) -> Vec> { + let st = s.doc().unwrap(); + let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { + return vec![]; + }; + let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] }; + strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect() +} + +#[test] +fn shape_three_d_format_and_rotation_round_trip() { + use crate::cmd::three_d::Shape3d; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + // The old `bevel` key now writes real 3-D XML, so it is drawn and saved. + s.execute("shape.effects", &json!({"bevel": "angle"})).unwrap(); + let e = fx(&s); + assert_eq!(e.bevel.as_deref(), Some("angle")); + assert!(e.raw3d.as_deref().is_some_and(|r| r.contains("bevelT") && r.contains("prst=\"angle\""))); + s.execute( + "shape.effects", + &json!({"threeD": {"bevelTopW": 9, "bevelBottom": "circle", "depth": 20, "contour": 2, "material": "metal", + "lighting": "harsh", "camera": "isometricTopUp", "rotX": 30, "rotY": -20, "z": 5, "bogus": 1}}), + ) + .unwrap(); + // Unknown names and non-finite numbers are ignored. + s.execute("shape.effects", &json!({"threeD": {"material": "gold", "depth": f64::NAN, "camera": 7}})).unwrap(); + let want = fx(&s).raw3d.map(|r| Shape3d::parse(&r)).unwrap(); + assert_eq!(want.top.as_ref().map(|b| (b.preset.as_str(), b.w, b.h)), Some(("angle", 9.0, 6.0))); + assert_eq!(want.bottom.as_ref().map(|b| b.preset.as_str()), Some("circle")); + assert_eq!((want.depth, want.contour, want.z), (20.0, 2.0, 5.0)); + assert_eq!((want.material.as_str(), want.light.as_str(), want.camera.as_str()), ("metal", "harsh", "isometricTopUp")); + assert_eq!(want.rot, Some((30.0, 340.0, 0.0))); + // PPTX save and reopen keeps every setting. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "d.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = s.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| sh.effects.as_ref()?.raw3d.clone()); + assert_eq!(Shape3d::parse(&back.unwrap()), want); +} + +#[test] +fn three_d_reset_and_flat_shapes_drop_the_xml() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + s.execute("shape.effects", &json!({"threeD": {"rotZ": 15, "depth": 4}})).unwrap(); + assert!(fx(&s).raw3d.is_some()); + s.execute("shape.effects", &json!({"threeD": {"reset": true}})).unwrap(); + assert_eq!((fx(&s).raw3d, fx(&s).bevel), (None, None)); + // Keeps colours it doesn't model. + let raw = ""; + let mut s3 = cmd::three_d::Shape3d::parse(raw); + s3.apply(&json!({"depth": 3})); + assert!(s3.to_xml().unwrap().contains("")); +} + +/// A presentation read off the UI thread enters the session through `file.adopt`: journaled like +/// `file.open`, and replayable from the journal without the staged copy. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn background_loads_are_adopted_through_a_command() { + let mut src = Session::with_new(); + src.execute("slide.new", &json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("deckcraft-adopt-{}.deckcraft", std::process::id())); + let path = path.to_string_lossy().to_string(); + src.execute("file.saveAs", &json!({"path": path})).unwrap(); + + let mut s = Session::new(); + let loaded = cmd::file::read_presentation_file(&path).unwrap(); + let token = s.stage_loaded(loaded); + let r = s.execute("file.adopt", &json!({"path": path, "token": token})).unwrap(); + assert_eq!(r["slides"], 2, "{r}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.as_str())); + let (id, params) = s.journal.last().cloned().unwrap(); + assert_eq!(id, "file.adopt"); + // The token was used up; replaying the entry reads the file again. + let mut replay = Session::new(); + replay.execute(&id, ¶ms).unwrap(); + assert_eq!(replay.doc().unwrap().doc.slides.len(), 2); + // A token staged for another path is not taken for this one. + let other = s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: vec![] }); + s.execute("file.adopt", &json!({"path": path, "token": other})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 2); + assert!(s.execute("file.adopt", &json!({})).is_err()); + std::fs::remove_file(&path).ok(); +} + +#[test] +fn every_command_id_is_registered_once() { + let mut seen = std::collections::HashSet::new(); + let dups: Vec<&str> = crate::command_specs().iter().map(|c| c.id).filter(|id| !seen.insert(*id)).collect(); + assert!(dups.is_empty(), "registered more than once: {dups:?}"); +} + +/// A deck whose ids are used up (a hostile file can arrive with next_id at the top of the +/// range) refuses to grow instead of handing out duplicate ids (#407). +#[test] +fn an_exhausted_id_space_refuses_growth() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).next_id = u32::MAX; + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("slide.new", &json!({})).is_err(), "a new slide is refused"); + assert!(s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).is_err()); + assert!(s.execute("slide.duplicate", &json!({})).is_err()); + assert!(s.execute("edit.duplicate", &json!({})).is_err()); + assert!(s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "Build"]})).is_err()); + assert!(s.execute("insert.textBox", &json!({"text": "Hi"})).is_err()); + // What the deck holds is untouched: same slides, same ids, no duplicates. + let after = s.doc().unwrap().doc.clone(); + assert_eq!(after.slides.len(), before.slides.len()); + let mut ids = vec![]; + deckcraft_model::walk(&after.slides[0].shapes, &mut |sh, _| ids.push(sh.id.0)); + ids.push(after.slides[0].id.0); + ids.push(after.next_id); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), ids.len(), "no id repeats"); +} diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs index 926c4289..19a91ae8 100644 --- a/crates/engine/src/tests_keys.rs +++ b/crates/engine/src/tests_keys.rs @@ -402,3 +402,38 @@ fn keys_never_panic_on_any_text() { } } } + +#[test] +fn select_into_another_body_settles_the_composition() { + let (mut s, a) = editing("hello", (0, 0)); + s.execute("insert.textBox", &json!({"rect": [60.0, 400.0, 600.0, 100.0], "text": "x"})).unwrap(); + let b = s.doc().unwrap().selection.shapes[0].0; + s.execute("text.set", &json!({"id": b, "text": "0123456789"})).unwrap(); + s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap(); + s.compose_text("ab", None).unwrap(); + s.select(|_, sel| { + sel.text = Some(crate::TextSel { shape: deckcraft_model::ShapeId(b), ..Default::default() }); + }) + .unwrap(); + assert!(s.doc().unwrap().composition.is_none()); + s.commit_text("X").unwrap(); + assert_eq!(text(&mut s, b), "X0123456789"); + assert_eq!(text(&mut s, a), "abhello"); +} + +#[test] +fn select_into_another_table_cell_settles_the_composition() { + let (mut s, _) = editing("x", (0, 0)); + let data = json!([["hello", "0123456789"]]); + let r = s.execute("insert.table", &json!({"rows": 1, "cols": 2, "rect": [60.0, 400.0, 600.0, 60.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.select(|_, sel| sel.text = Some(crate::TextSel { shape: id, cell: Some((0, 0)), ..Default::default() })).unwrap(); + s.compose_text("ab", None).unwrap(); + s.select(|_, sel| sel.text = Some(crate::TextSel { shape: id, cell: Some((0, 1)), ..Default::default() })).unwrap(); + assert!(s.doc().unwrap().composition.is_none()); + s.commit_text("X").unwrap(); + let st = s.doc().unwrap(); + let Some(deckcraft_model::ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { panic!("not a table") }; + let cell = |c| t.cell(0, c).map(|x| x.text.text()).unwrap_or_default(); + assert_eq!((cell(0), cell(1)), ("abhello".to_string(), "X0123456789".to_string())); +} diff --git a/crates/engine/src/thesaurus.rs b/crates/engine/src/thesaurus.rs new file mode 100644 index 00000000..10cc207d --- /dev/null +++ b/crates/engine/src/thesaurus.rs @@ -0,0 +1,119 @@ +//! Review › Thesaurus: synonyms from a small built-in English word list (`data/thesaurus.txt`), +//! looked up offline so it works in the browser build too. + +const DATA: &str = include_str!("../data/thesaurus.txt"); + +/// One sense of a word: its headword and synonyms. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Sense { + pub word: String, + pub synonyms: Vec, +} + +fn senses_of(head: &str) -> Vec { + DATA.lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('|')) + .filter(|(h, _)| h.trim() == head) + .map(|(h, s)| Sense { word: h.trim().to_string(), synonyms: s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() }) + .collect() +} + +/// The senses for `word`, trying simple English endings when the word itself isn't listed +/// ("goals" → "goal", "improved" → "improve", "growing" → "grow"). Synonyms take the word's +/// capitalisation ("Goal" → "Aim", "GOAL" → "AIM"). +pub fn lookup(word: &str) -> Vec { + let w: String = word.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != '-').chars().take(64).collect(); + let lower = w.to_lowercase(); + if lower.is_empty() { + return vec![]; + } + let mut tries = vec![lower.clone()]; + for (suffix, add) in [("ies", "y"), ("es", ""), ("s", ""), ("ied", "y"), ("ed", "e"), ("ed", ""), ("ing", "e"), ("ing", "")] { + if let Some(stem) = lower.strip_suffix(suffix) + && stem.chars().count() >= 2 + { + tries.push(format!("{stem}{add}")); + } + } + let Some(found) = tries.iter().map(|t| senses_of(t)).find(|v| !v.is_empty()) else { return vec![] }; + let upper = w.chars().count() > 1 && w.chars().all(|c| !c.is_lowercase()); + let title = !upper && w.chars().next().is_some_and(char::is_uppercase); + found + .into_iter() + .map(|s| Sense { + word: s.word, + synonyms: s + .synonyms + .into_iter() + .map(|x| { + if upper { + x.to_uppercase() + } else if title { + let mut c = x.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + x + } + }) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn senses_endings_and_case() { + let g = lookup("goal"); + assert_eq!(g.len(), 1); + assert!(g[0].synonyms.contains(&"objective".to_string())); + assert_eq!(lookup("change").len(), 2, "verb and noun senses"); + assert_eq!(lookup("Goals")[0].synonyms[0], "Aim"); + assert_eq!(lookup("GOAL")[0].synonyms[0], "AIM"); + assert_eq!(lookup("improved")[0].word, "improve"); + assert_eq!(lookup("growing")[0].word, "grow"); + assert_eq!(lookup("strategies")[0].word, "strategy"); + assert_eq!(lookup("“goal,”")[0].word, "goal", "punctuation around the word"); + assert!(lookup("").is_empty() && lookup("zzxq").is_empty() && lookup(" ").is_empty()); + assert!(lookup(&"a".repeat(10_000)).is_empty()); + } + + #[test] + fn data_is_well_formed() { + for l in DATA.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let (h, s) = l.split_once('|').unwrap_or_else(|| panic!("no `|`: {l}")); + assert_eq!(h, h.trim().to_lowercase(), "{l}"); + let syn: Vec<&str> = s.split(',').map(str::trim).collect(); + assert!(syn.len() >= 2 && syn.iter().all(|x| !x.is_empty() && *x != h), "{l}"); + let mut d = syn.clone(); + d.sort(); + d.dedup(); + assert_eq!(d.len(), syn.len(), "duplicate synonym: {l}"); + } + } + + #[test] + fn commands_look_up_and_replace_the_word_at_the_caret() { + use serde_json::json; + let mut s = crate::Session::with_new(); + let id = s.execute("insert.textBox", &json!({"rect": [50, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let r = s.execute("review.thesaurus", &json!({})).unwrap(); + assert_eq!(r["word"], "Goal"); + assert_eq!(r["senses"][0]["synonyms"][0], "Aim"); + s.execute("review.replaceWord", &json!({"with": "Aim"})).unwrap(); + let text = |s: &crate::Session| { + s.doc().unwrap().shapes().iter().find(|x| x.id.0 as u64 == id).and_then(|x| x.text.as_ref()).map(|t| t.paragraphs[0].text()).unwrap() + }; + assert_eq!(text(&s), "Our Aim is growth."); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "Our Goal is growth."); + assert_eq!(s.execute("review.thesaurus", &json!({"word": "growth."})).unwrap()["senses"][0]["word"], "growth"); + assert!(s.execute("review.replaceWord", &json!({"with": ""})).is_err()); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..f1acd9e9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -21,6 +21,9 @@ pub enum PointerKind { Move, DoubleClick, TripleClick, + /// Secondary-button press (right click), before the context menu opens: selects what's under + /// the pointer the way PowerPoint does, without editing text or starting a drag. + ContextClick, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -40,6 +43,10 @@ pub struct PointerEvent { pub mods: Mods, /// Hit tolerance in points (≈ 4 screen px at the current zoom). pub tol: f64, + /// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke + /// is stored at full pen width. + #[serde(default)] + pub pressure: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -76,6 +83,24 @@ pub enum Hit { id: ShapeId, index: usize, }, + /// A control handle (`out`: the outgoing one) of the selected vertex in Edit Points. + Handle { + id: ShapeId, + path: usize, + index: usize, + out: bool, + }, + /// A vertex of a shape whose points are being edited. + Vertex { + id: ShapeId, + path: usize, + index: usize, + }, + /// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`. + Warp { + id: ShapeId, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -85,6 +110,21 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The frame of the box whose text is being edited (a band on either side of its edge): + /// pressing there leaves text editing and drags the box, like PowerPoint. + Frame { + id: ShapeId, + }, + /// The right edge of table column `col` (dragging it resizes the column). + TableColumn { + id: ShapeId, + col: usize, + }, + /// The bottom edge of table row `row` (dragging it resizes the row). + TableRow { + id: ShapeId, + row: usize, + }, } #[derive(Clone, Debug)] @@ -119,16 +159,51 @@ enum Drag { index: usize, orig: Xfrm, }, + Vertex { + id: ShapeId, + path: usize, + index: usize, + }, + Handle { + id: ShapeId, + path: usize, + index: usize, + out: bool, + }, + /// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults). + Warp { + id: ShapeId, + index: usize, + name: String, + base: Vec, + orig: Xfrm, + }, Marquee { start: Point, add: bool, }, + /// Dragging drawing guide `index`. + Guide { + index: usize, + }, Create { start: Point, }, TextSelect { shape: ShapeId, }, + /// Dragging a table border: the right edge of column `index`, or the bottom edge of row + /// `index`. `start` is the table-local press point, `size` the column's width or the row's + /// drawn height then, `frame` where the table was drawn. + TableBorder { + id: ShapeId, + column: bool, + index: usize, + start: Point, + size: f64, + frame: Affine, + moved: bool, + }, Ink { points: Vec<(f64, f64, f32)>, }, @@ -144,6 +219,10 @@ enum Drag { /// Shape-gallery names that draw a freeform path instead of a preset. pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")]; +/// Draw Table's pencil and eraser, as `ToolKind::Shape` presets (Insert › Table › Draw Table). +pub const DRAW_TABLE: &str = "drawTable"; +pub const TABLE_ERASER: &str = "tableEraser"; + pub fn is_freeform_tool(name: &str) -> bool { FREEFORM_TOOLS.iter().any(|f| f.0 == name) } @@ -178,6 +257,20 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// The shape in Edit Points: its vertices are handles instead of the box's. + pub edit_points: Option, + /// The vertex (path, index) whose control handles show in Edit Points. + pub edit_vertex: Option<(usize, usize)>, + /// The point type last chosen for a vertex (path, index, type): handle drags follow it. + pub vertex_kind: Option<(usize, usize, String)>, + /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. + pub guides_visible: bool, + /// The guide being dragged and where it is now (slide points); `None` once dropped. + pub guide_drag: Option<(usize, f64)>, + /// The drawing guide under the pointer, for the cursor. + pub guide_hover: Option, + /// Pressure from the pointer event being handled, if the device reported one. + pressure: Option, } impl ToolState { @@ -185,6 +278,11 @@ impl ToolState { self.drag.is_some() } + /// The shape in Edit Points, while it is still the one selected shape. + pub fn editing_points(&self, st: &crate::DocState) -> Option { + self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some()) + } + /// A freeform path is being drawn (Enter/Escape/double-click finish it). pub fn drawing_freeform(&self) -> bool { matches!(self.drag, Some(Drag::Freeform { .. })) @@ -204,6 +302,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { (pts, rot) } +/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the +/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on. +pub fn wordart_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + w: f64, + h: f64, +) -> Vec { + if sh.text.is_none() { + return Vec::new(); + } + let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() }; + let bp = deckcraft_model::resolve::body(&ctx, sh); + let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() }; + let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect; + deckcraft_geom::warp::handles(name, &bp.warp_adj, tr) +} + +/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults). +fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) { + let Some(text) = sh.text.as_mut() else { return }; + if !value.is_finite() || index > 7 { + return; + } + let defaults = deckcraft_geom::warp::adjust_defaults(name); + let n = defaults.len().max(index + 1).min(8); + if n == 0 { + return; + } + let mut adj = Vec::with_capacity(n); + for i in 0..n { + let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) }; + adj.push(v); + } + text.body.warp = Some(name.to_string()); + text.body.warp_adj = adj; +} + impl Session { pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; @@ -215,6 +352,23 @@ impl Session { /// What's under `p` on the current slide. pub fn hit_test(&self, p: Point, tol: f64) -> Option { let st = self.active()?; + // In Edit Points, the edited shape's vertices come first. + if let Some(id) = self.tool.editing_points(st) + && let Some(sh) = st.shape(id) + { + if let Some((path, index)) = self.tool.edit_vertex + && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for (h, out) in [(hout, true), (hin, false)] { + if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { + return Some(Hit::Handle { id, path, index, out }); + } + } + } + if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) { + return Some(Hit::Vertex { id, path, index }); + } + } // Handles of selected shapes first. if st.selection.text.is_none() || st.selection.shapes.len() == 1 { for id in st.selection.shapes.iter().rev() { @@ -239,6 +393,11 @@ impl Session { return Some(Hit::Adjust { id: *id, index: hd.index }); } } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } let (pts, rot) = box_handles(&x); if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); @@ -250,6 +409,13 @@ impl Session { } } } + // The frame of the text being edited: grabbing it moves the box. + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + { + return Some(Hit::Frame { id: e.id }); + } // Shapes, topmost first. // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { @@ -267,6 +433,7 @@ impl Session { pub fn pointer(&mut self, ev: PointerEvent) -> Result { let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; let p = Point::new(ev.x, ev.y); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } @@ -286,6 +453,8 @@ impl Session { return Ok(Value::Null); } self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); if line_tool { self.update_sites(p, tol, None); @@ -299,6 +468,7 @@ impl Session { PointerKind::Drag => self.drag(p, ev.mods, tol), PointerKind::Up => self.up(p, ev.mods), PointerKind::DoubleClick => self.double_click(p, tol), + PointerKind::ContextClick => self.context_click(p, tol), PointerKind::TripleClick => { if self.active().is_some_and(|d| d.selection.text.is_some()) { self.execute("text.selectParagraph", &json!({})) @@ -309,7 +479,7 @@ impl Session { } } - fn begin(&mut self, label: &str) { + pub(crate) fn begin(&mut self, label: &str) { if let Some(st) = self.active_mut() && st.interaction.is_none() { @@ -326,6 +496,14 @@ impl Session { } } + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. + pub(crate) fn commit_named(&mut self, label: &str) { + let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); + if ours { + self.commit(); + } + } + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { // Next click of a freeform: close on the start point, else add a vertex. @@ -346,6 +524,10 @@ impl Session { self.tool.path_closing = false; return Ok(Value::Null); } + ToolKind::Shape { preset } if preset == TABLE_ERASER => { + // Click a border to merge the cells on either side; elsewhere does nothing. + return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); + } ToolKind::Shape { .. } | ToolKind::TextBox => { self.tool.drag = Some(Drag::Create { start: p }); self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); @@ -355,40 +537,52 @@ impl Session { if mode == "eraser" { return self.erase_at(p, tol); } - self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] }); - self.tool.ink_preview = vec![(p.x, p.y, 0.5)]; + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; return Ok(Value::Null); } ToolKind::Select => {} } - let st = self.doc()?; // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } - } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + self.tool.drag = Some(Drag::TextSelect { shape }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); + // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; + // a click anywhere else leaves Edit Points. + if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { + if let Some(Hit::Vertex { id, path, index }) = hit { + if mods.cmd { + return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); + } + self.tool.edit_vertex = Some((path, index)); + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Vertex { id, path, index }); + return Ok(json!({"vertex": [path, index]})); + } + if let Some(Hit::Handle { id, path, index, out }) = hit { + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + return Ok(json!({"handle": [path, index]})); + } + let st = self.doc()?; + if let Some(sh) = st.shape(eid) + && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) + { + if mods.cmd { + return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); + } + return Ok(Value::Null); + } + self.tool.edit_points = None; + self.tool.edit_vertex = None; + } let st = self.doc()?; match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Size"); @@ -407,11 +601,36 @@ impl Session { self.begin("Rotate"); self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); } + // Only reached outside Edit Points (handled above). + Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} Some(Hit::Adjust { id, index }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -432,8 +651,10 @@ impl Session { let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. - let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); + // placeholders take the caret straight away like PowerPoint. A click in a selected + // table puts the caret in the clicked cell. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p)); let ids2 = ids.clone(); self.select(|_, sel| { sel.shapes = ids2; @@ -451,6 +672,14 @@ impl Session { let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); } } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } None => { let add = mods.shift || mods.cmd; self.select(|_, sel| { @@ -470,6 +699,13 @@ impl Session { fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { @@ -601,6 +837,36 @@ impl Session { Ok(()) })?; } + Drag::Vertex { id, path, index } => { + self.tool.drag = Some(Drag::Vertex { id, path, index }); + self.edit_vertex(id, path, index, p)?; + return Ok(Value::Null); + } + Drag::Handle { id, path, index, out } => { + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + let side = if out { "out" } else { "in" }; + if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p) { + log::debug!("edit handle: {e}"); + } + return Ok(Value::Null); + } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } @@ -613,6 +879,13 @@ impl Session { } let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); line_xfrm(s0, q, 0.0) + } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) + && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() + { + // The pencil inside a table draws a straight border, across or down. + let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; + let r = Rect::from_points(start, q); + Xfrm::new(r.x0, r.y0, r.width(), r.height()) } else { let mut r = Rect::from_points(start, p); if mods.shift { @@ -630,9 +903,10 @@ impl Session { self.tool.preview = Some(x); } Drag::TextSelect { shape } => { + // The selection stays in the shape or table cell where it started. let st = self.doc()?; - if let Some(sh) = st.shape(shape) { - let pos = text_pos(st, sh, p); + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); self.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.caret = pos; @@ -640,6 +914,26 @@ impl Session { })?; } } + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } Drag::Freeform { mode, mut points, pressed } => { // Dragging scribbles (except for curves, which only take clicked points). if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { @@ -653,7 +947,7 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } Drag::Ink { mut points } => { - points.push((p.x, p.y, 0.5)); + points.push(self.ink_sample(p)); self.tool.ink_preview = points.clone(); self.tool.drag = Some(Drag::Ink { points }); } @@ -661,6 +955,20 @@ impl Session { Ok(Value::Null) } + /// Moves a vertex during a drag (one undo step for the whole drag). + fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { + let r = cmd::points::move_point_raw(self, id, path, index, p); + if let Err(e) = r { + log::debug!("edit point: {e}"); + } + Ok(()) + } + + fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point) -> Result { + let kind = self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2); + cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind})) + } + fn up(&mut self, p: Point, mods: Mods) -> Result { let drag = self.tool.drag.take(); self.tool.guides.clear(); @@ -668,12 +976,21 @@ impl Session { self.tool.glue = None; let mut out = Value::Null; match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click. - let st = self.doc()?; - if let Some(sh) = st.shape(id) { - let pos = text_pos(st, sh, at); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? { out = json!({"editing": id}); } } @@ -708,6 +1025,16 @@ impl Session { let preview = self.tool.preview.take(); let dragged = (p - start).hypot() > 2.0; let res = match &kind { + ToolKind::Shape { preset } if preset == DRAW_TABLE => { + // Inside a table: split cells along the line. Elsewhere: a one-cell table. + if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { + self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) + } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { + self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? + } else { + Value::Null + } + } ToolKind::Shape { preset } => { let line = deckcraft_geom::preset::is_line_like(preset); let rect = match preview.filter(|_| dragged) { @@ -741,7 +1068,8 @@ impl Session { } _ => Value::Null, }; - if !self.tool.sticky { + // The table pencil stays on until you pick another tool, as in PowerPoint. + if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { self.tool.kind = ToolKind::Select; } out = res; @@ -792,8 +1120,12 @@ impl Session { self.tool.sticky = false; return Ok(Value::Null); } - let st = self.doc()?; - if st.selection.text.is_some() { + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { return self.execute("text.selectWord", &json!({})); } match self.hit_test(p, tol) { @@ -801,21 +1133,26 @@ impl Session { let st = self.doc()?; let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; match &sh.kind { - ShapeKind::Table(_) => { - let x = xfrm_of(&st.doc, &st.selection, &sh); - let local = x.affine().inverse() * p; - if let ShapeKind::Table(t) = &sh.kind { - let cell = deckcraft_render_cell(t, &table_rows(st, t), local); - return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]})); + ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); } - Ok(Value::Null) - } - ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - let pos = text_pos(st, &sh, p); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; self.execute("text.selectWord", &json!({})) } - ShapeKind::Group { .. } => Ok(Value::Null), + ShapeKind::Group { .. } => { + // A group is a single selection unit, but its text stays editable: + // double-click reaches the text-holding member under the cursor. + match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { + Some((mid, msh)) if !msh.is_line() => { + let pos = text_pos(st, &msh, p); + self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; + self.execute("text.selectWord", &json!({})) + } + _ => Ok(Value::Null), + } + } _ => { self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); Ok(Value::Null) @@ -826,6 +1163,147 @@ impl Session { } } + /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the + /// box being edited, the caret and text selection stay (the menu then acts on the text). On a + /// selected shape (or its handles) the selection stays, multiple selection included; on + /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, + /// starts a drag or adds an undo step. + fn context_click(&mut self, p: Point, tol: f64) -> Result { + if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { + return Ok(Value::Null); + } + let st = self.doc()?; + // The same zone in which a left press places the caret (`down`). + if let Some(t) = &st.selection.text + && !t.notes + && t.cell.is_none() + && let Some(sh) = st.shape(t.shape) + && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) + { + return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); + } + let hit = self.hit_test(p, tol); + let id = match &hit { + Some( + Hit::Shape { id, .. } + | Hit::Resize { id, .. } + | Hit::Rotate { id } + | Hit::Adjust { id, .. } + | Hit::LineEnd { id, .. } + | Hit::Handle { id, .. } + | Hit::Vertex { id, .. } + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. } + | Hit::Warp { id, .. }, + ) => Some(*id), + None => None, + }; + if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = id.into_iter().collect(); + })?; + } + let st = self.doc()?; + Ok(json!({ + "hit": serde_json::to_value(&hit).unwrap_or_default(), + "selected": st.selection.shapes, + "editing": st.selection.text.is_some(), + })) + } + + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + let st = self.doc()?; + let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; + let Some(sh) = st.shape(t.shape) else { return Ok(None) }; + // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). + // A table cell: anywhere over the table. + let inside = match t.cell { + None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), + Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), + }; + if !inside + || matches!( + self.hit_test(p, tol), + Some( + Hit::Resize { .. } + | Hit::Rotate { .. } + | Hit::Adjust { .. } + | Hit::Warp { .. } + | Hit::Frame { .. } + | Hit::TableColumn { .. } + | Hit::TableRow { .. } + ) + ) + { + return Ok(None); + } + let old_cell = t.cell; + if old_cell.is_some() + && let Some(f) = cmd::table::table_frame(st, sh) + { + t.cell = f.cell_at(f.local(p)).or(old_cell); + } + let pos = cmd::text::text_pos_at(st, &t, p); + // A selection can't span cells: a click in another cell starts afresh. + let extend = extend && t.cell == old_cell; + let cell = t.cell; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.cell = cell; + tt.caret = pos; + if !extend { + tt.anchor = pos; + } + } + })?; + Ok(Some((t.shape, pos))) + } + + /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell + /// at `p`. Returns whether editing started. + fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(false) }; + let params = match cmd::table::table_frame(st, sh) { + Some(f) => { + let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; + let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); + json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) + } + None => { + let pos = text_pos(st, sh, p); + json!({"id": id, "at": [pos.0, pos.1]}) + } + }; + self.execute("text.edit", ¶ms)?; + Ok(true) + } + + /// Press on a table border: dragging it resizes the column or row. Editing a cell of the + /// table goes on; otherwise the table becomes the selection. + fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { + let st = self.doc()?; + let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; + let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); + let (start, frame) = (f.local(p), f.affine); + if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); + Ok(()) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } @@ -929,7 +1407,7 @@ impl Session { let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); - cmd::edit::reid(doc, &mut copies); + cmd::edit::reid("edit.duplicate", doc, &mut copies)?; for (c, b) in copies.iter_mut().zip(boxes) { c.xfrm = Some(b); c.ph = None; @@ -942,6 +1420,22 @@ impl Session { }) } + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { @@ -960,6 +1454,15 @@ impl Session { (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), (size.height, Rect::new(0.0, size.height, size.width, size.height)), ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } for sh in st.shapes() { if exclude.contains(&sh.id) || sh.hidden { continue; @@ -1040,6 +1543,15 @@ impl Session { nx } + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; @@ -1049,7 +1561,7 @@ impl Session { let id = match existing { Some(id) => id, None => { - let id = ShapeId(doc.alloc_id()); + let id = ShapeId(doc.alloc_id().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?); let size = doc.slide_size; let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; list.push(Shape { @@ -1097,6 +1609,79 @@ fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) } +/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. +/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is +/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. +fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let ShapeKind::Group { children, child } = &sh.kind else { return None }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) +} + +/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. +/// `to_abs` maps this level's child space to slide space. +fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { + let q = to_abs.inverse() * p; + for c in children.iter().rev() { + let Some(cx) = c.xfrm else { continue }; + let local = cx.affine().inverse() * q; + if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { + continue; + } + if let ShapeKind::Group { children: gc, child: gch } = &c.kind { + let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); + if let Some(id) = member_at(gc, nested, p, tol) { + return Some(id); + } + } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { + return Some(c.id); + } + } + None +} + +/// The box whose text is being edited on the slide (not speaker notes, not a table cell). +struct EditedBox { + id: ShapeId, + xfrm: Xfrm, + /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. + movable: bool, +} + +fn edited_box(st: &crate::DocState) -> Option { + let t = st.selection.text.as_ref()?; + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) +} + +/// Where a point falls against the box of the text being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditZone { + /// Well inside: the caret goes there. + Inside, + /// On the frame: a band `tol` wide on either side of the box edge. + Frame, + Outside, +} + +/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside +/// small boxes the band narrows to a quarter of the box so their text stays reachable. +fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { + let l = x.affine().inverse() * p; + let (w, h) = (x.w.max(0.0), x.h.max(0.0)); + let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; + if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { + return EditZone::Outside; + } + let inner = tol.min(w / 4.0).min(h / 4.0); + if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } +} + fn contains_rect(outer: Rect, inner: Rect) -> bool { inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 } @@ -1210,6 +1795,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if near && let Some(f) = cmd::table::table_frame(st, sh) { + match f.border_at(f.local(p), tol) { + Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), + Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), + None => {} + } + } match &sh.kind { ShapeKind::Group { children, child } => { // A group is hit when any child is hit; the group is what gets selected. @@ -1259,7 +1853,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option Option (usize, usize) { - let x = xfrm_of(&st.doc, &st.selection, sh); - let t = TextSel { shape: sh.id, ..Default::default() }; - match cmd::text::layout_for(st, &t) { - Some(l) => { - // The text's own transform: flips don't mirror text, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p; - let h = l.hit(local); - (h.para, h.ch) - } - None => (0, 0), - } + cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) } /// Row heights of table `t` as drawn: rows grow to fit their text. @@ -1292,29 +1880,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec cmd::table_drawn_heights(&st.doc, &st.selection, t) } -/// Which table cell contains a table-local point (`heights` from [`table_rows`]). -fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) { - let mut y = 0.0; - let mut row = heights.len().saturating_sub(1); - for (i, h) in heights.iter().enumerate() { - if p.y < y + h { - row = i; - break; - } - y += h; - } - let mut x = 0.0; - let mut col = t.cols.len().saturating_sub(1); - for (i, w) in t.cols.iter().enumerate() { - if p.x < x + w { - col = i; - break; - } - x += w; - } - (row, col) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/tests/show_clicks.rs b/crates/engine/tests/show_clicks.rs new file mode 100644 index 00000000..92da2f7f --- /dev/null +++ b/crates/engine/tests/show_clicks.rs @@ -0,0 +1,243 @@ +//! Slide show clicks on decks that went through a PPTX file: links, click-triggered animations and +//! what a slide looks like when the show comes back to it. + +use std::sync::Arc; + +use deckcraft_anim::{Click, HitShape, ShowAction, ShowState}; +use deckcraft_geom::Xfrm; +use deckcraft_model::anim::{AnimClass, AnimStart, Animation, Transition}; +use deckcraft_model::text::{Action, Hyperlink, TextBody}; +use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, SlideId, defaults}; + +fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { + id: ShapeId(p.alloc_id().expect("id")), + name: name.into(), + xfrm: Some(Xfrm::new(x, y, w, h)), + text: Some(TextBody::from_text(text)), + ..Default::default() + } +} + +fn link_to(slide: SlideId) -> Option { + Some(Hyperlink { + action: Action::Slide { slide }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }) +} + +/// Clicking `trigger` makes `target` disappear. +fn disappear_on_click(trigger: ShapeId, target: ShapeId) -> Animation { + Animation { + shape: target, + class: AnimClass::Exit, + effect: "disappear".into(), + start: AnimStart::OnClick, + duration_ms: 0, + trigger: Some(trigger), + ..Default::default() + } +} + +fn add_slide(p: &mut Presentation, shapes: Vec, animations: Vec, advance_on_click: bool) -> SlideId { + let layout = defaults::layout_of_kind(p, LayoutType::Blank).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout).expect("slide"); + s.shapes = shapes; + s.animations = animations; + s.transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() }); + let id = s.id; + p.slides.push(Arc::new(s)); + id +} + +/// Drops the slide `new_presentation` starts with. +fn start(p: &mut Presentation) { + p.slides.clear(); +} + +/// The slide show window's click handling, on a deck read from a PPTX file. +struct Show { + pres: Presentation, + state: ShowState, +} + +impl Show { + fn open(p: &Presentation) -> Show { + let bytes = deckcraft_pptx::export(p).expect("export"); + let pres = deckcraft_pptx::import(&bytes).expect("import"); + let state = ShowState::new(&pres); + Show { pres, state } + } + + fn hits(&self, x: f64, y: f64) -> (Vec, Vec) { + let placed = deckcraft_render::place_slide(&self.pres, self.state.slide); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let hits = placed + .shapes + .iter() + .map(|s| HitShape { + id: s.id, + parent: s.parent, + is_group: s.is_group, + link: s.link.as_ref().map(|l| l.action.clone()), + under_pointer: s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1, + }) + .collect(); + let visible = placed + .shapes + .iter() + .map(|s| { + let a = self.state.anim_state(&tl, s.id, 0.0); + a.visible && a.opacity > 0.0 + }) + .collect(); + (hits, visible) + } + + /// Click the middle of the shape called `name` on the current slide. + fn click(&mut self, name: &str) -> Click { + let (x, y) = self.centre(name); + let (hits, vis) = self.hits(x, y); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v); + self.state.click(&self.pres, &tl, &hits, &visible, 0.0, None) + } + + fn centre(&self, name: &str) -> (f64, f64) { + let slide = &self.pres.slides[self.state.slide]; + let s = slide.shapes.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("no shape {name}")); + let x = s.xfrm.expect("xfrm"); + (x.x + x.w / 2.0, x.y + x.h / 2.0) + } + + fn shown(&self, name: &str) -> bool { + let slide = &self.pres.slides[self.state.slide]; + let Some(s) = slide.shapes.iter().find(|s| s.name == name) else { return false }; + let a = self.state.anim_state(&ShowState::timeline(&self.pres, self.state.slide), s.id, 0.0); + a.visible && a.opacity > 0.0 + } + + fn is_target(&self, name: &str) -> bool { + let (x, y) = self.centre(name); + let (hits, vis) = self.hits(x, y); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v); + let id = self.pres.slides[self.state.slide].shapes.iter().find(|s| s.name == name).map(|s| s.id); + self.state.target(&self.pres, &tl, &hits, &visible).map(|t| t.shape) == id + } +} + +/// An image that disappears when clicked, revealing a button that links to slide 2 and disappears +/// as well; slide 2 links back; the slides do not advance on a click. +fn hide_and_reveal_deck() -> Presentation { + let mut p = defaults::new_presentation(None); + start(&mut p); + let s2_id = SlideId(p.alloc_id().expect("id")); + let _ = s2_id; + let message = shape(&mut p, "Message", "Both hidden", 100.0, 100.0, 600.0, 300.0); + let mut button = shape(&mut p, "Button", "Image hidden", 100.0, 100.0, 600.0, 300.0); + let picture = shape(&mut p, "Picture", "Picture", 100.0, 100.0, 600.0, 300.0); + let animations = vec![disappear_on_click(picture.id, picture.id), disappear_on_click(button.id, button.id)]; + let first = add_slide(&mut p, vec![message, button.clone(), picture], animations, false); + let mut back = shape(&mut p, "Back", "Go back", 100.0, 100.0, 600.0, 300.0); + back.click = link_to(first); + let second = add_slide(&mut p, vec![back], vec![], false); + // The button links to slide 2. + let s = Arc::make_mut(&mut p.slides[0]); + button.click = link_to(second); + if let Some(b) = s.shapes.iter_mut().find(|x| x.name == "Button") { + b.click = button.click.clone(); + } + p +} + +#[test] +fn hidden_shapes_stay_hidden_and_dead_when_the_show_comes_back() { + let mut show = Show::open(&hide_and_reveal_deck()); + assert!(show.is_target("Picture") && show.shown("Picture")); + assert_eq!(show.click("Picture"), Click::Animated); + assert!(!show.shown("Picture")); + // The button was under the picture; now it is on top. Its click follows the link. + assert!(show.is_target("Button")); + assert_eq!(show.click("Button"), Click::Show(ShowAction::Slide(1))); + assert_eq!(show.state.slide, 1); + assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0))); + // Back on slide 1: both are gone, only the message is left, and it does nothing. + assert!(!show.shown("Picture") && !show.shown("Button") && show.shown("Message")); + assert!(!show.is_target("Message")); + for _ in 0..3 { + assert_eq!(show.click("Message"), Click::None); + } + assert_eq!(show.state.slide, 0); +} + +/// A board of three tiles, each linking to its question, which links back; the tile hides itself. +#[test] +fn a_game_board_hides_the_tiles_already_played() { + let mut p = defaults::new_presentation(None); + start(&mut p); + let mut tiles: Vec = (0..3).map(|i| shape(&mut p, &format!("Tile {i}"), "100", 50.0 + 250.0 * i as f64, 100.0, 200.0, 100.0)).collect(); + let animations: Vec = tiles.iter().map(|t| disappear_on_click(t.id, t.id)).collect(); + let board = add_slide(&mut p, tiles.clone(), animations, true); + let questions: Vec = (0..3) + .map(|i| { + let mut back = shape(&mut p, "Back", &format!("Question {i}"), 100.0, 100.0, 400.0, 200.0); + back.click = link_to(board); + add_slide(&mut p, vec![back], vec![], true) + }) + .collect(); + let s = Arc::make_mut(&mut p.slides[0]); + for (t, q) in tiles.iter_mut().zip(&questions) { + if let Some(x) = s.shapes.iter_mut().find(|x| x.id == t.id) { + x.click = link_to(*q); + } + } + let mut show = Show::open(&p); + for (i, played) in [1usize, 0, 2].into_iter().enumerate() { + assert_eq!(show.click(&format!("Tile {played}")), Click::Show(ShowAction::Slide(1 + played))); + assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0))); + // Exactly the tiles played so far are gone. + let gone: Vec = (0..3).map(|t| !show.shown(&format!("Tile {t}"))).collect(); + assert_eq!(gone.iter().filter(|g| **g).count(), i + 1, "{gone:?}"); + assert!(gone[played]); + assert!(!show.is_target(&format!("Tile {played}"))); + } +} + +/// A score that cycles: each digit hides itself when clicked and shows the next; the last shows the +/// first again. Every digit has its own one-click trigger. +#[test] +fn a_score_ring_goes_round() { + let mut p = defaults::new_presentation(None); + start(&mut p); + let n = 4usize; + let digits: Vec = (0..n).map(|i| shape(&mut p, &format!("Score {i}"), &i.to_string(), 400.0, 100.0, 100.0, 100.0)).collect(); + let mut anims = vec![]; + for (i, d) in digits.iter().enumerate() { + anims.push(disappear_on_click(d.id, d.id)); + anims.push(Animation { + shape: digits[(i + 1) % n].id, + class: AnimClass::Entrance, + effect: "appear".into(), + start: AnimStart::WithPrevious, + duration_ms: 0, + trigger: Some(d.id), + ..Default::default() + }); + } + add_slide(&mut p, digits, anims, true); + let mut show = Show::open(&p); + let showing = |show: &Show| -> Vec { (0..n).filter(|i| show.shown(&format!("Score {i}"))).collect() }; + assert_eq!(showing(&show), vec![0]); + for click in 0..2 * n { + let now = click % n; + assert_eq!(showing(&show), vec![now], "before click {click}"); + assert!(show.is_target(&format!("Score {now}"))); + assert_eq!(show.click(&format!("Score {now}")), Click::Animated); + } + assert_eq!(showing(&show), vec![0]); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8c788325..03267384 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -318,6 +318,11 @@ pub struct FontDb { /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, + /// Held while installed fonts are loaded, so threads that need the same family wait for one + /// load instead of racing it: what a lookup finds doesn't depend on which thread got there + /// first. + #[cfg(not(target_arch = "wasm32"))] + loading: Mutex<()>, } #[cfg(not(target_arch = "wasm32"))] @@ -388,9 +393,16 @@ fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()))) } +/// The tables skrifa reads glyph outlines from: `glyf` (TrueType), `CFF `, `CFF2` and `VARC`. For a +/// face with none of them (bitmaps only, or outlines in Apple's `hvgl` table), [`FontDb::outline`] +/// returns an empty path for every glyph. +#[cfg(not(target_arch = "wasm32"))] +const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; + /// (family, style) of every face in the font file at `path`, reading only its table directories /// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A /// variable font is cataloged under its default style; its named instances appear once it loads. +/// Faces with none of the [`OUTLINE_TABLES`] are left out. #[cfg(not(target_arch = "wasm32"))] fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { use std::io::{Read, Seek, SeekFrom}; @@ -419,6 +431,9 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { let dir = read_at(start.into(), 12)?; let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; let records = read_at(u64::from(start) + 12, tables * 16)?; + if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) { + return None; + } let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); if len > MAX_NAME_TABLE { @@ -444,6 +459,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { .collect() } +/// Folder depth below a font folder that the system scan descends. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_DEPTH: usize = 12; +/// Directory entries the system scan looks at in all. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_ENTRIES: usize = 200_000; +/// The largest font file read (the biggest CJK collections are a few hundred MB). +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_FONT_FILE: u64 = 512 << 20; + +/// A regular file of a size a font can have. +#[cfg(not(target_arch = "wasm32"))] +fn font_file_ok(meta: &std::fs::Metadata) -> bool { + meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. /// Empty on wasm. pub fn system_font_dirs() -> Vec { @@ -488,10 +519,87 @@ pub fn system_font_dirs() -> Vec { dirs.push(h.join(".fonts")); dirs.push(h.join(".local/share/fonts")); } + let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().into_owned()); + dirs.extend(xdg_font_dirs(env("XDG_DATA_HOME").as_deref(), env("XDG_DATA_DIRS").as_deref(), home.as_deref())); + // Inside Flatpak the host's fonts are mounted here. + dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into)); + // Folders fontconfig is configured with (NixOS, Guix, custom setups). + for conf in fontconfig_files() { + if let Ok(text) = std::fs::read_to_string(&conf) { + dirs.extend(fontconfig_dirs(&text, env("XDG_DATA_HOME").as_deref(), home.as_deref())); + } + } } + let mut seen = std::collections::HashSet::new(); + dirs.retain(|d| seen.insert(d.clone())); dirs } +/// `/fonts` for `XDG_DATA_HOME` (default `~/.local/share`) and each `XDG_DATA_DIRS` +/// entry (default `/usr/local/share:/usr/share`). +fn xdg_font_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&std::path::Path>) -> Vec { + let mut v = vec![]; + match data_home.filter(|d| !d.is_empty()) { + Some(d) => v.push(std::path::Path::new(d).join("fonts")), + None => { + if let Some(h) = home { + v.push(h.join(".local/share/fonts")); + } + } + } + let dirs = data_dirs.filter(|d| !d.is_empty()).unwrap_or("/usr/local/share:/usr/share"); + v.extend(dirs.split(':').filter(|d| d.starts_with('/')).take(64).map(|d| std::path::Path::new(d).join("fonts"))); + v +} + +/// The fontconfig configuration files: `/etc/fonts/fonts.conf` and `/etc/fonts/conf.d/*.conf`. +fn fontconfig_files() -> Vec { + let mut v = vec![std::path::PathBuf::from("/etc/fonts/fonts.conf"), std::path::PathBuf::from("/etc/fonts/local.conf")]; + if let Ok(entries) = std::fs::read_dir("/etc/fonts/conf.d") { + let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "conf")).collect(); + files.sort(); + files.truncate(512); + v.extend(files); + } + v +} + +/// The `` folders of a fontconfig file (`prefix="xdg"` is under `XDG_DATA_HOME`, a leading +/// `~` under home; relative and `cachedir` entries are skipped). +fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path::Path>) -> Vec { + let mut v = vec![]; + let mut rest = text; + while let Some(start) = rest.find("` or ``, not ``. + if !(after.starts_with('>') || after.starts_with(' ')) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { break }; + let attrs = &after[..gt]; + let body = &after[gt + 1..]; + let Some(end) = body.find("") else { break }; + let path = body[..end].trim(); + rest = &body[end..]; + if attrs.trim_end().ends_with('/') || path.is_empty() || v.len() >= 64 { + continue; + } + let p = if attrs.contains("prefix=\"xdg\"") { + let base = data_home.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))); + base.map(|b| b.join(path)) + } else if let Some(r) = path.strip_prefix("~/") { + home.map(|h| h.join(r)) + } else if path.starts_with('/') { + Some(std::path::PathBuf::from(path)) + } else { + None + }; + v.extend(p); + } + v +} + /// One face found in a font file: index, family, style and (variable fonts) the named instance's /// axis settings. type Found = (u32, String, String, Vec<([u8; 4], f32)>); @@ -689,6 +797,8 @@ impl FontDb { loaded_families: Mutex::new(std::collections::HashSet::new()), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + #[cfg(not(target_arch = "wasm32"))] + loading: Mutex::new(()), } } @@ -754,14 +864,33 @@ impl FontDb { /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec) -> usize { - let data = Arc::new(bytes); - let mut added = 0; - for (i, family, style, coords) in enumerate_faces(&data) { - if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { - continue; + self.add_fonts(vec![bytes]) + } + + /// Add font files together: lookups see all of their faces or none, never part of a family. + /// Returns the number of faces added. + fn add_fonts(&self, files: Vec>) -> usize { + let present = |faces: &[Arc], family: &str, style: &str| { + faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) + }; + let mut new = Vec::new(); + for bytes in files { + let data = Arc::new(bytes); + for (i, family, style, coords) in enumerate_faces(&data) { + if present(&self.read_faces(), &family, &style) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + new.push(Arc::new(f)); + } } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + } + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let mut added = 0; + for f in new { + // Checked again under the lock: another thread may have added the same face. + if !present(&faces, &f.family, &f.style) { + faces.push(f); added += 1; } } @@ -804,24 +933,41 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); - let mut stack = self.font_dirs.clone(); + let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect(); // Each folder once, however links lead back to it. let mut visited = std::collections::HashSet::new(); - while let Some(d) = stack.pop() { + let mut entries = 0usize; + while let Some((d, depth)) = stack.pop() { if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { continue; } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { + // A link to `/` or a vast tree mustn't turn start-up into a disk crawl. + entries += 1; + if entries > MAX_SCAN_ENTRIES { + log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries"); + stack.clear(); + break; + } let p = e.path(); - if p.is_dir() { - stack.push(p); + // Follows links: what matters is what the entry leads to. + let Ok(meta) = std::fs::metadata(&p) else { continue }; + if meta.is_dir() { + if depth < MAX_SCAN_DEPTH { + stack.push((p, depth + 1)); + } continue; } let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } + // Only regular files: opening a FIFO named like a font blocks until something + // writes to it (start-up hung), and a device never ends. + if !font_file_ok(&meta) { + continue; + } for (family, style) in file_face_names(&p) { found.push(CatalogEntry { family, style, path: p.clone() }); } @@ -833,9 +979,19 @@ impl FontDb { n } - /// Load the files of the installed `family`. Returns whether any face was added. + /// Load the files of the installed `family` (faces already loaded are kept). Returns whether + /// it is loaded. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { + let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + self.load_family(family, &loading) + } + + /// [`FontDb::load_cataloged`] for a caller already holding `loading`. + #[cfg(not(target_arch = "wasm32"))] + fn load_family(&self, family: &str, _loading: &std::sync::MutexGuard<'_, ()>) -> bool { + // Not skipped when some face of the family is loaded: a lookup by a Windows family name + // loads only that style's file, and the family's other files must still load later. let paths: Vec = { let cat = self.read_catalog(); let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); @@ -843,13 +999,8 @@ impl FontDb { p.dedup(); p }; - let mut any = false; - for p in paths { - if let Ok(data) = std::fs::read(&p) { - any |= self.add_font(data) > 0; - } - } - any + self.add_fonts(paths.iter().filter(|p| std::fs::metadata(p).is_ok_and(|m| font_file_ok(&m))).filter_map(|p| std::fs::read(p).ok()).collect()); + self.is_loaded(family) } /// Resolve a family + style to a face, falling back to the closest style of the family, then to @@ -1061,6 +1212,7 @@ impl FontDb { if c.is_control() || c.is_whitespace() { return false; } + let seen = self.read_faces().len(); { let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); if !sys.enabled || sys.misses.contains(&c) { @@ -1069,9 +1221,18 @@ impl FontDb { } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + // Another thread may have loaded a face for `c` (faces are only ever added), or found + // there is none, while this one waited. + if self.read_faces().len() != seen && covered(self) { + return true; + } + if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) { + return false; + } for fam in SYSTEM_FALLBACKS { if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); + self.load_family(fam, &loading); if covered(self) { return true; } @@ -1266,3 +1427,53 @@ impl OutlinePen for FlipPen { self.0.close_path(); } } + +#[cfg(test)] +mod font_dir_tests { + use std::path::{Path, PathBuf}; + + use super::*; + + #[test] + fn xdg_data_dirs_contribute_their_fonts_folders() { + let home = Path::new("/home/ann"); + assert_eq!( + xdg_font_dirs(None, None, Some(home)), + [home.join(".local/share/fonts"), "/usr/local/share/fonts".into(), "/usr/share/fonts".into()] + ); + let v = xdg_font_dirs(Some("/data/ann"), Some("/nix/profile/share:relative:/var/lib/flatpak/exports/share"), Some(home)); + assert_eq!(v, [PathBuf::from("/data/ann/fonts"), "/nix/profile/share/fonts".into(), "/var/lib/flatpak/exports/share/fonts".into()]); + } + + #[test] + fn fontconfig_dir_entries_are_read() { + let conf = r#" + + /usr/share/fonts + fonts + ~/.fonts + relative/fonts + /var/cache/fontconfig + ignored + /nix/store/abc-fonts/share/fonts +"#; + let home = Path::new("/home/ann"); + let v = fontconfig_dirs(conf, None, Some(home)); + assert_eq!( + v, + [PathBuf::from("/usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), "/nix/store/abc-fonts/share/fonts".into()] + ); + assert!(fontconfig_dirs("/never closed", None, Some(home)).is_empty()); + } + + #[test] + #[cfg(target_os = "linux")] + fn linux_dirs_include_flatpak_host_fonts_without_duplicates() { + let d = system_font_dirs(); + assert!(d.contains(&PathBuf::from("/run/host/user-fonts"))); + let mut u = d.clone(); + u.sort(); + u.dedup(); + assert_eq!(u.len(), d.len()); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 79bcde85..a9f0c050 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -40,6 +40,43 @@ pub fn japanese_fonts() -> impl Iterator { CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan")) } +/// The TrueType or OpenType font inside a PresentationML embedded-font part +/// (`application/x-fontdata`). +/// +/// The part is the font itself, or an Embedded OpenType wrapper around it (the `0x504C` magic +/// at byte 34, from the W3C Embedded OpenType submission). A MicroType Express subset is neither, +/// so this returns none; the original part stays in the file for the round trip. +pub fn embedded_sfnt(part: &[u8]) -> Option<&[u8]> { + eot_payload(part).or_else(|| sfnt_or_collection(part).then_some(part)) +} + +fn sfnt_or_collection(b: &[u8]) -> bool { + b.len() >= 12 + && (b.starts_with(&[0x00, 0x01, 0x00, 0x00]) + || b.starts_with(b"OTTO") + || b.starts_with(b"true") + || b.starts_with(b"typ1") + || b.starts_with(b"ttcf")) +} + +/// Font data at the end of an Embedded OpenType blob, when the header is one. +fn eot_payload(b: &[u8]) -> Option<&[u8]> { + if b.len() < 82 || u16::from_le_bytes([b[34], b[35]]) != 0x504C { + return None; + } + let version = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?); + if !matches!(version, 0x0001_0000 | 0x0002_0001 | 0x0002_0002) { + return None; + } + let eot_size = u32::from_le_bytes(b.get(0..4)?.try_into().ok()?) as usize; + let font_size = u32::from_le_bytes(b.get(4..8)?.try_into().ok()?) as usize; + if font_size < 12 || eot_size < font_size.saturating_add(82) || eot_size > b.len() { + return None; + } + let font = b.get(eot_size - font_size..eot_size)?; + sfnt_or_collection(font).then_some(font) +} + /// Japanese faces in document-fallback order: Mincho (serif) families first, matching the /// serif default text font, then the others; Regular before other styles. pub fn japanese_document_fonts() -> Vec<&'static CraftFont> { @@ -218,6 +255,34 @@ mod tests { assert!(accepted.covers('A')); } + #[test] + fn concurrent_lookups_all_find_an_installed_family() { + // Threads resolving an installed family nobody had loaded yet used to fall back to + // another face when a different thread finished loading it first, so the same text was + // laid out in different fonts depending on timing. + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for _ in 0..20 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + let start = std::sync::Barrier::new(8); + let got: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + db.face("Hack", "Regular").family.clone() + }) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn windows_family_names_find_their_faces() { // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's @@ -254,6 +319,36 @@ mod tests { } } + #[test] + fn faces_without_outlines_are_not_cataloged() { + // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in + // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag + // keeps the table directory sorted. + let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); + let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; + let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); + semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); + let loaded = FontDb::with_font_dirs(Vec::new()); + assert_eq!(loaded.add_font(semibold.clone()), 1); + let face = loaded.face("DeckCraft Legacy", "Semibold"); + assert!(face.covers('A')); + assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); + // The system font scan lists the Regular, which has outlines, and leaves out the Semibold + // under both of its names. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); + std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); + assert!(!db.has_family("DeckCraft Legacy Semibold")); + let _ = std::fs::remove_dir_all(&dir); + } + } + #[test] fn missing_document_fonts_are_drawn_in_calibri() { // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. @@ -328,4 +423,55 @@ mod tests { assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); } } + + /// An Embedded OpenType header of `version` around `font`, with the font at the end. + fn wrap_eot(font: &[u8], version: u32) -> Vec { + let mut h = vec![0u8; 82]; + let total = 82 + font.len(); + h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); + h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); + h[8..12].copy_from_slice(&version.to_le_bytes()); + h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); + h.extend_from_slice(font); + h + } + + #[test] + fn embedded_open_type_yields_the_font() { + let font = epaint_default_fonts::UBUNTU_LIGHT; + assert_eq!(embedded_sfnt(font), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); + assert!(embedded_sfnt(b"not a font file").is_none()); + assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); + let mut truncated = wrap_eot(font, 0x0002_0001); + truncated.truncate(40); + assert!(embedded_sfnt(&truncated).is_none()); + } + + /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them + /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and + /// still finds the real font beside them. + #[cfg(unix)] + #[test] + fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/format/src/finite.rs b/crates/format/src/finite.rs new file mode 100644 index 00000000..834108a3 --- /dev/null +++ b/crates/format/src/finite.rs @@ -0,0 +1,185 @@ +//! A serde serializer adapter that writes non-finite floats as 0. +//! +//! serde_json writes NaN and ±infinity as `null`, which doesn't read back as a number: a single +//! NaN anywhere in the model (a rotation, a crop, a stroke width) made a saved file fail to open +//! as "damaged". [`Finite`] wraps any `Serialize` value and replaces those floats on the way out; +//! everything else passes through unchanged. + +use serde::ser::{self, Serialize, Serializer}; + +/// `T`, serialized with non-finite floats written as 0. +pub struct Finite<'a, T: ?Sized>(pub &'a T); + +impl Serialize for Finite<'_, T> { + fn serialize(&self, s: S) -> Result { + self.0.serialize(Wrap(s)) + } +} + +struct Wrap(S); + +macro_rules! pass { + ($($m:ident($t:ty)),* $(,)?) => { + $(fn $m(self, v: $t) -> Result { self.0.$m(v) })* + }; +} + +impl Serializer for Wrap { + type Ok = S::Ok; + type Error = S::Error; + type SerializeSeq = Wrap; + type SerializeTuple = Wrap; + type SerializeTupleStruct = Wrap; + type SerializeTupleVariant = Wrap; + type SerializeMap = Wrap; + type SerializeStruct = Wrap; + type SerializeStructVariant = Wrap; + + pass!( + serialize_bool(bool), + serialize_i8(i8), + serialize_i16(i16), + serialize_i32(i32), + serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), + serialize_u16(u16), + serialize_u32(u32), + serialize_u64(u64), + serialize_u128(u128), + serialize_char(char), + serialize_str(&str), + serialize_bytes(&[u8]), + ); + + fn serialize_f32(self, v: f32) -> Result { + self.0.serialize_f32(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_f64(self, v: f64) -> Result { + self.0.serialize_f64(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_none(self) -> Result { + self.0.serialize_none() + } + fn serialize_some(self, v: &T) -> Result { + self.0.serialize_some(&Finite(v)) + } + fn serialize_unit(self) -> Result { + self.0.serialize_unit() + } + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.0.serialize_unit_struct(name) + } + fn serialize_unit_variant(self, name: &'static str, i: u32, variant: &'static str) -> Result { + self.0.serialize_unit_variant(name, i, variant) + } + fn serialize_newtype_struct(self, name: &'static str, v: &T) -> Result { + self.0.serialize_newtype_struct(name, &Finite(v)) + } + fn serialize_newtype_variant(self, name: &'static str, i: u32, variant: &'static str, v: &T) -> Result { + self.0.serialize_newtype_variant(name, i, variant, &Finite(v)) + } + fn serialize_seq(self, len: Option) -> Result { + self.0.serialize_seq(len).map(Wrap) + } + fn serialize_tuple(self, len: usize) -> Result { + self.0.serialize_tuple(len).map(Wrap) + } + fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_tuple_struct(name, len).map(Wrap) + } + fn serialize_tuple_variant(self, name: &'static str, i: u32, variant: &'static str, len: usize) -> Result { + self.0.serialize_tuple_variant(name, i, variant, len).map(Wrap) + } + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len).map(Wrap) + } + fn serialize_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_struct(name, len).map(Wrap) + } + fn serialize_struct_variant( + self, + name: &'static str, + i: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct_variant(name, i, variant, len).map(Wrap) + } + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} + +macro_rules! compound { + ($tr:ident, $m:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn $m(&mut self, v: &T) -> Result<(), S::Error> { + self.0.$m(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +compound!(SerializeSeq, serialize_element); +compound!(SerializeTuple, serialize_element); +compound!(SerializeTupleStruct, serialize_field); +compound!(SerializeTupleVariant, serialize_field); + +impl ser::SerializeMap for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_key(&mut self, k: &T) -> Result<(), S::Error> { + self.0.serialize_key(&Finite(k)) + } + fn serialize_value(&mut self, v: &T) -> Result<(), S::Error> { + self.0.serialize_value(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } +} + +macro_rules! fields { + ($tr:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> { + self.0.serialize_field(key, &Finite(v)) + } + fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> { + self.0.skip_field(key) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +fields!(SerializeStruct); +fields!(SerializeStructVariant); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_floats_are_written_as_zero_and_nothing_else_changes() { + #[derive(serde::Serialize)] + struct T { + a: f64, + b: Option, + c: Vec<(f32, f64)>, + d: Option, + e: std::collections::BTreeMap, + } + let t = T { a: f64::NAN, b: Some(f64::INFINITY), c: vec![(f32::NEG_INFINITY, 1.5)], d: None, e: [("k".to_string(), f64::NAN)].into() }; + let s = serde_json::to_string(&Finite(&t)).unwrap_or_default(); + assert_eq!(s, r#"{"a":0.0,"b":0.0,"c":[[0.0,1.5]],"d":null,"e":{"k":0.0}}"#); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..679dfc7f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -4,6 +4,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod finite; + use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -18,6 +20,17 @@ pub const EXTENSION: &str = "deckcraft"; pub const VERSION: u32 = 1; /// Largest entry we will inflate (guards against zip bombs). const MAX_ENTRY: u64 = 2 << 30; +/// All embedded media together, inflated (#393). +const MAX_TOTAL_MEDIA: u64 = 4 << 30; +/// Media items one file may list. +const MAX_MEDIA_ITEMS: usize = 100_000; + +/// What one load may inflate. +#[derive(Clone, Copy)] +struct Budget { + total: u64, + items: usize, +} #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -55,7 +68,7 @@ pub fn save(p: &Presentation) -> Result> { generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation: p.clone(), }; - let json = serde_json::to_vec(&m).map_err(|e| FormatError::Write(e.to_string()))?; + let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; for item in &p.media { @@ -87,35 +100,76 @@ pub fn sniff(bytes: &[u8]) -> bool { } fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> { + read_entry_max(z, name, MAX_ENTRY) +} + +/// Read one member, refusing it before inflating if it declares more than `max` bytes, and +/// stopping at `max` if it inflates further than it declared. +fn read_entry_max(z: &mut zip::ZipArchive>, name: &str, max: u64) -> Result> { let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; - if f.size() > MAX_ENTRY { + if f.size() > max { return Err(FormatError::Damaged(format!("{name} is too large"))); } let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize); - f.take(MAX_ENTRY).read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + let mut f = f.take(max.saturating_add(1)); + f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + if out.len() as u64 > max { + return Err(FormatError::Damaged(format!("{name} is too large"))); + } Ok(out) } -/// Read a presentation. +/// Read a presentation. Embedded media that is missing or damaged is left empty; use +/// [`load_checked`] to learn which. pub fn load(bytes: &[u8]) -> Result { + load_checked(bytes).map(|(p, _)| p) +} + +/// Read a presentation and list the embedded media that couldn't be read (missing, too large +/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage. +pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { + load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS }) +} + +fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec)> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; - let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; + let m = parse_manifest(&json)?; if m.format != "deckcraft" { return Err(FormatError::NotOurs(m.format)); } let mut p = m.presentation; + let mut damaged = vec![]; + if p.media.len() > budget.items { + return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items))); + } + // A repeated id names the same member: keep the first item, so the member is inflated once + // and the deck can be saved again (a zip can't hold two `media/`). + let mut seen = std::collections::HashSet::new(); + p.media.retain(|m| seen.insert(m.id)); + let mut left = budget.total; for item in &mut p.media { - if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) { - item.data = Arc::new(data); + let name = format!("media/{}", item.id.0); + let linked_only = item.link.is_some() && z.index_for_name(&name).is_none(); + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into())); + } + match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) { + Ok(data) => { + left -= data.len() as u64; + item.data = Arc::new(data); + } + Err(_) if linked_only => {} + Err(e) => damaged.push(e.to_string()), } } repair(&mut p); - Ok(p) + Ok((p, damaged)) } -/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, -/// ids are unique, tables are rectangular. +/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, tables +/// are rectangular, and duplicate ids are re-id'd — unless the deck has used every id, in which +/// case a duplicate stays (growth is refused; see the comment in the loop). pub fn repair(p: &mut Presentation) { if p.masters.is_empty() { let fresh = defaults::blank_presentation(p.slide_size, Default::default(), false); @@ -128,7 +182,10 @@ pub fn repair(p: &mut Presentation) { let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none()); let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id)); if needs_layout || dup { - let new_id = if dup { Some(deckcraft_model::SlideId(p.alloc_id())) } else { None }; + // An exhausted id space leaves a duplicate slide as it is: the engine refuses to + // grow such a deck (edits by id stay ambiguous for the duplicate), and the duplicate + // stays visible rather than the file failing to open (#407). + let new_id = if dup { p.alloc_id().map(deckcraft_model::SlideId) } else { None }; if let Some(s) = p.slides.get_mut(i) { let s = Arc::make_mut(s); if needs_layout { @@ -140,25 +197,220 @@ pub fn repair(p: &mut Presentation) { } } } - if !(p.slide_size.width.is_finite() && p.slide_size.width >= 1.0 && p.slide_size.height.is_finite() && p.slide_size.height >= 1.0) { - p.slide_size = defaults::WIDE; + p.slide_size = sane_slide_size(p.slide_size); + // The notes page only lays out notes, but its size divides too. + if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) { + p.notes_size = Presentation::default().notes_size; + } + sane_numbers(p); +} + +/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows. +pub const SLIDE_SIDE: std::ops::RangeInclusive = 72.0..=4032.0; + +/// A slide size the views and renderer can always lay out: a missing, zero, negative or +/// non-finite side gives the 16:9 default, and each side is brought into [`SLIDE_SIDE`]. +pub fn sane_slide_size(s: deckcraft_model::Size) -> deckcraft_model::Size { + let ok = |v: f64| v.is_finite() && v > 0.0; + if !(ok(s.width) && ok(s.height)) { + return defaults::WIDE; + } + deckcraft_model::Size::new(s.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()), s.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end())) +} + +/// Slides read from an outline: each title with its body paragraphs (level 0–8, text). +type OutlineSlides = Vec<(String, Vec<(u8, String)>)>; + +/// Append a Title and Content slide per outline entry; the number made. +fn add_outline_slides(p: &mut Presentation, slides: OutlineSlides) -> usize { + let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; + let mut made = 0; + for (title, body) in slides { + // An exhausted id space makes no new slides: what fit is kept (#407). + let Some(mut s) = defaults::new_slide(p, lay) else { break }; + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(&title)); + } + if let Some(b) = s.shapes.get_mut(1) { + for (level, line) in body { + let tb = b.text.get_or_insert_with(TextBody::default); + let mut para = Paragraph::new(&line); + para.level = level.min(8); + tb.paragraphs.push(para); + } + } + p.slides.push(Arc::new(s)); + made += 1; + } + made +} + +/// Most `null`s standing in for numbers that [`parse_manifest`] patches in one file, and the +/// candidates it tries for each. +const MAX_NULL_FIXES: usize = 256; +const MAX_TRIES_PER_FIX: usize = 32; +/// Bytes [`parse_manifest`] may re-parse in all while patching, so a hostile file full of `null`s +/// can't keep it busy: past this the file is reported damaged. +const MAX_REPARSE_BYTES: usize = 256 * 1024 * 1024; + +/// Where a parse failed, comparable: later is progress. +fn failure(src: &[u8]) -> std::result::Result { + serde_json::from_slice::(src).map_err(|e| (e.to_string(), (e.line(), e.column()))) +} + +/// Parse `presentation.json`. serde_json writes a NaN or infinite number as `null`, which then +/// fails to read back as a number ("invalid type: null, expected f64"), so DeckCraft could save +/// a file it couldn't open. Each such `null` is read as 0 instead ([`repair`] then brings the +/// value into range); anything else that doesn't parse is damage. +/// +/// serde reports the failing `null` exactly, except inside tagged enums (buffered), where it +/// reports the end of the enclosing object: there the `null`s before that point are tried +/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since +/// [`finite::Finite`] never contain such `null`s; this reads older ones.) +fn parse_manifest(json: &[u8]) -> Result { + let mut src = json.to_vec(); + let mut last = match failure(&src) { + Ok(m) => return Ok(m), + Err(f) => f, + }; + let mut budget = MAX_REPARSE_BYTES; + 'fixes: for _ in 0..MAX_NULL_FIXES { + let (msg, pos) = &last; + let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number"); + if !number { + break; + } + let end = offset(&src, pos.0, pos.1).unwrap_or(src.len()); + let mut progressed = false; + let candidates: Vec = value_nulls_before(&src, end + 4).take(MAX_TRIES_PER_FIX).collect(); + for at in candidates { + let (Some(head), Some(tail)) = (src.get(..at), src.get(at.saturating_add(4)..)) else { continue }; + let Some(left) = budget.checked_sub(src.len()) else { break 'fixes }; + budget = left; + let mut next = Vec::with_capacity(src.len()); + next.extend_from_slice(head); + next.push(b'0'); + next.extend_from_slice(tail); + match failure(&next) { + Ok(m) => return Ok(m), + // The patch shortened the text by 3 bytes before the error: compare positions + // allowing for that. + Err(f) if f.1 > (pos.0, pos.1.saturating_sub(3)) => { + src = next; + last = f; + progressed = true; + break; + } + Err(_) => {} + } + } + if !progressed { + break; + } + } + Err(FormatError::Damaged(last.0)) +} + +/// Byte offset of a 1-based `line`:`column`. +fn offset(src: &[u8], line: usize, column: usize) -> Option { + let mut start = 0; + for _ in 1..line { + start += src.get(start..)?.iter().position(|b| *b == b'\n')? + 1; + } + Some((start + column).min(src.len())) +} + +/// Starts of `null` tokens in value position (after `:`, `[` or `,`) before `end`, nearest first. +fn value_nulls_before(src: &[u8], end: usize) -> impl Iterator + '_ { + let end = end.min(src.len()); + (0..end.saturating_sub(3)).rev().filter(move |&i| { + src.get(i..i + 4) == Some(b"null") + && src[..i].iter().rev().find(|b| !b.is_ascii_whitespace()).is_some_and(|b| matches!(b, b':' | b'[' | b',')) + && src.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric()) + }) +} + +/// Largest coordinate or size kept, in points (about 350 m; PowerPoint's own limit is far smaller). +const MAX_COORD: f64 = 1.0e6; + +/// Numbers no view can draw or no file should keep: non-finite positions, sizes and angles become +/// 0, huge ones are clamped, angles are brought into 0–360, crops into a sane range, font sizes +/// into 1–4000 pt and line widths into 0–1584 pt (PowerPoint's limits). +fn sane_xfrm(x: &mut deckcraft_model::Xfrm) { + let fin = |v: f64| if v.is_finite() { v } else { 0.0 }; + x.x = fin(x.x).clamp(-MAX_COORD, MAX_COORD); + x.y = fin(x.y).clamp(-MAX_COORD, MAX_COORD); + x.w = fin(x.w).clamp(0.0, MAX_COORD); + x.h = fin(x.h).clamp(0.0, MAX_COORD); + x.rot = fin(x.rot).rem_euclid(360.0); + if x.rot >= 360.0 { + x.rot = 0.0; + } +} + +fn sane_run(p: &mut deckcraft_model::text::RunProps) { + p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0)); +} + +fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + sane_xfrm(x); + } + if let Some(l) = sh.line.as_mut() { + l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0)); + } + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + sane_run(&mut para.end_props); + for r in &mut para.runs { + sane_run(&mut r.props); + } + } + } + match &mut sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => { + for c in &mut fill.crop { + *c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 }; + } + } + deckcraft_model::ShapeKind::Group { children, child } => { + sane_xfrm(child); + sane_shapes(children, depth + 1); + } + _ => {} + } + } +} + +/// [`sane_shapes`] over every slide, layout and master; slides are only copied when they change. +pub fn sane_numbers(p: &mut Presentation) { + for slide in &mut p.slides { + let mut fixed = (**slide).clone(); + sane_shapes(&mut fixed.shapes, 0); + if fixed != **slide { + *slide = Arc::new(fixed); + } + } + for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) { + let mut fixed = (**m).clone(); + sane_shapes(&mut fixed.shapes, 0); + for l in &mut fixed.layouts { + sane_shapes(&mut l.shapes, 0); + } + if fixed != **m { + *m = Arc::new(fixed); + } } - p.slide_size.width = p.slide_size.width.min(4000.0); - p.slide_size.height = p.slide_size.height.min(4000.0); } /// Outline text: one slide per unindented line; tab-indented lines become body bullets at their /// level (Insert ▸ Slides from Outline, View ▸ Outline copy). pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { - let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; - let mut made = 0; - let mut cur: Option = None; - let flush = |p: &mut Presentation, s: Option, made: &mut usize| { - if let Some(s) = s { - p.slides.push(Arc::new(s)); - *made += 1; - } - }; + let mut slides: OutlineSlides = Vec::new(); for raw in text.lines() { if raw.trim().is_empty() { continue; @@ -166,25 +418,69 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { let tabs = raw.chars().take_while(|c| *c == '\t').count(); let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2; let level = tabs.max(spaces); - let line = raw.trim().trim_start_matches(['-', '*', '•']).trim(); + let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string(); if level == 0 { - flush(p, cur.take(), &mut made); - let mut s = defaults::new_slide(p, lay); - if let Some(t) = s.shapes.first_mut() { - t.text = Some(TextBody::from_text(line)); - } - cur = Some(s); - } else if let Some(s) = cur.as_mut() - && let Some(body) = s.shapes.get_mut(1) - { - let tb = body.text.get_or_insert_with(TextBody::default); - let mut para = Paragraph::new(line); - para.level = (level - 1).min(8) as u8; - tb.paragraphs.push(para); + slides.push((line, Vec::new())); + } else if let Some((_, body)) = slides.last_mut() { + body.push(((level - 1).min(8) as u8, line)); } } - flush(p, cur, &mut made); - made + add_outline_slides(p, slides) +} + +/// An ATX heading's text (CommonMark: up to three spaces, one to six `#`, then a space or the end; +/// a closing run of `#` is dropped). +fn md_heading(line: &str) -> Option<&str> { + let t = line.strip_prefix(" ").or_else(|| line.strip_prefix(" ")).or_else(|| line.strip_prefix(' ')).unwrap_or(line); + let hashes = t.chars().take_while(|c| *c == '#').count(); + let rest = t.get(hashes..)?; + if !(1..=6).contains(&hashes) || !(rest.is_empty() || rest.starts_with([' ', '\t'])) { + return None; + } + let rest = rest.trim(); + let closed = rest.trim_end_matches('#'); + Some(if closed.is_empty() || closed.ends_with([' ', '\t']) { closed.trim_end() } else { rest }) +} + +/// A list item's nesting level (two spaces or a tab each) and text: `-`, `*` or `+`, or digits +/// then `.` or `)`, followed by a space. +fn md_list_item(line: &str) -> Option<(u8, &str)> { + let indent: usize = line.chars().take_while(|c| matches!(c, ' ' | '\t')).map(|c| if c == '\t' { 2 } else { 1 }).sum(); + let t = line.trim_start(); + let rest = match t.strip_prefix(['-', '*', '+']) { + Some(r) => r, + None => { + let digits = t.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 9 { + return None; + } + t.get(digits..)?.strip_prefix(['.', ')'])? + } + }; + rest.starts_with([' ', '\t']).then(|| ((indent / 2).min(8) as u8, rest.trim())) +} + +/// Markdown outline text (`.md`): each heading starts a slide with its text as the title; list +/// items under it become bullets nested by their indentation, other lines body paragraphs. Text +/// before the first heading is skipped, as indented lines before the first title are in the +/// plain format. A file without headings is read as a plain outline ([`outline_to_slides`]). +pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize { + if !text.lines().any(|l| md_heading(l).is_some()) { + return outline_to_slides(p, text); + } + let mut slides: OutlineSlides = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + if let Some(title) = md_heading(line) { + slides.push((title.to_string(), Vec::new())); + } else if let Some((_, body)) = slides.last_mut() { + let (level, text) = md_list_item(line).unwrap_or((0, line.trim())); + body.push((level, text.to_string())); + } + } + add_outline_slides(p, slides) } /// The deck as outline text (titles unindented, body paragraphs tab-indented by level). @@ -218,10 +514,93 @@ pub fn slides_to_outline(p: &Presentation) -> String { mod tests { use super::*; + /// The same package without the entries `drop` names. + fn without(bytes: &[u8], drop: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != drop { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() + } + + #[test] + fn missing_or_corrupt_media_is_reported() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); + let good = save(&p).unwrap(); + assert!(load_checked(&good).unwrap().1.is_empty()); + // Missing member. + let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + assert!(q.media(id).is_some_and(|m| m.data.is_empty())); + // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (_, damaged) = load_checked(&bad).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + // Linked media saved without its bytes isn't damage. + let mut l = Presentation::default(); + let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); + if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { + m.link = Some("file:///movies/v.mp4".into()); + } + assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); + } + + #[test] + fn media_budget_is_cumulative() { + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![1; 600]); + p.add_media("b.bin", "application/octet-stream", vec![2; 600]); + let bytes = save(&p).unwrap(); + // Exactly at the limit opens; one byte under is refused, though each item fits. + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); + } + + #[test] + fn repeated_media_ids_are_read_once() { + use std::io::Write; + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); + let good = save(&p).unwrap(); + // The same file with its media item listed 51 times. + let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + let mut json = vec![]; + f.read_to_end(&mut json).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); + let media = &mut v["presentation"]["media"]; + let one = media[0].clone(); + *media = serde_json::Value::Array(vec![one; 51]); + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + let bytes = out.finish().unwrap().into_inner(); + // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. + let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; + assert_eq!(q.media.len(), 1); + assert_eq!(q.media[0].data.len(), 1000); + // And the deck saves again. + assert!(save(&q).is_ok()); + } + #[test] fn roundtrip_with_media() { let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]); + let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); let bytes = save(&p).unwrap(); assert!(sniff(&bytes)); let q = load(&bytes).unwrap(); @@ -241,6 +620,144 @@ mod tests { assert!(load(&b).is_err()); } + /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each + /// such number repaired (it failed as "invalid type: null, expected f64"). + #[test] + fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); + } + + /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, + /// including a `null` inside a tagged enum, where serde reports only the enclosing object. + #[test] + fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } + } + + /// Only `null`s where numbers belong are patched: other damage still fails. + #[test] + fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); + } + + #[test] + fn degenerate_slide_sizes_are_repaired() { + use deckcraft_model::Size; + let cases = [ + ((0.0, 0.0), (960.0, 540.0)), + ((-5.0, 540.0), (960.0, 540.0)), + ((f64::NAN, 540.0), (960.0, 540.0)), + ((f64::INFINITY, 1.0), (960.0, 540.0)), + ((1.0, 1.0), (72.0, 72.0)), + ((0.5, 4000.0), (72.0, 4000.0)), + ((1e9, 20.0), (4032.0, 72.0)), + ((720.0, 540.0), (720.0, 540.0)), + ]; + for ((w, h), want) in cases { + let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; + repair(&mut p); + assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); + assert_eq!(p.notes_size, Presentation::default().notes_size); + } + } + + /// A file whose slide size is null, a string or missing a side (written by hand or by + /// another tool; NaN is also saved as null) opens with the default size rather than failing + /// as damaged. + #[test] + fn files_with_unreadable_slide_sizes_still_open() { + let saved = save(&Presentation::default()).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + for size in [ + serde_json::json!({"width": null, "height": 540}), + serde_json::json!({"width": "big", "height": 540}), + serde_json::json!({"height": 0}), + serde_json::json!(null), + serde_json::json!(7), + ] { + let mut m = json.clone(); + m["presentation"]["slideSize"] = size.clone(); + let mut buf = Cursor::new(Vec::new()); + { + let mut w = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + w.start_file("mimetype", o).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("presentation.json", o).unwrap(); + w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); + w.finish().unwrap(); + } + let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); + } + } + #[test] fn repair_fixes_missing_layout_and_size() { let mut p = Presentation::default(); @@ -251,6 +768,17 @@ mod tests { assert_eq!(p.slide_size.width, 960.0); } + /// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is + /// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). + #[test] + fn repair_keeps_a_duplicate_it_cannot_reid() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + p.slides.push(p.slides[0].clone()); + repair(&mut p); + assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); + assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); + } + #[test] fn outline_roundtrip() { let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); @@ -259,4 +787,23 @@ mod tests { let o = slides_to_outline(&p); assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); } + + #[test] + fn markdown_headings_and_list_items() { + assert_eq!(md_heading("# Intro"), Some("Intro")); + assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); + assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); + assert_eq!(md_heading("#"), Some("")); + assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); + for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { + assert_eq!(md_heading(not), None, "{not:?}"); + } + assert_eq!(md_list_item("- a"), Some((0, "a"))); + assert_eq!(md_list_item(" * b"), Some((2, "b"))); + assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); + assert_eq!(md_list_item("12) d"), Some((0, "d"))); + for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { + assert_eq!(md_list_item(not), None, "{not:?}"); + } + } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index dd41db37..3be72e03 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ pub mod morph; pub mod preset; +pub mod squircle; pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 6a295359..07cac01c 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -541,6 +541,27 @@ fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> { pts } +/// The text rectangle ECMA-376's presetShapeDefinitions give a star (`inner` is the adjust value +/// over 50000), where known: star4's is the square inside its inner points, star5's spans the +/// inner pentagon from its upper side points to its bottom inner point. +fn star_text_rect(n: usize, w: f64, h: f64, inner: f64) -> Option { + let (hc, vc) = (w / 2.0, h / 2.0); + match n { + 4 => { + let (dx, dy) = (hc * inner * 45f64.to_radians().cos(), vc * inner * 45f64.to_radians().sin()); + Some(Rect::new(hc - dx, vc - dy, hc + dx, vc + dy)) + } + 5 => { + let (swd2, shd2, svc) = (hc * 1.05146, vc * 1.10557, vc * 1.10557); + let (iwd2, ihd2) = (swd2 * inner, shd2 * inner); + let sdx1 = iwd2 * 18f64.to_radians().cos(); + let sdy1 = ihd2 * 54f64.to_radians().sin(); + Some(Rect::new(hc - sdx1, svc - sdy1, hc + sdx1, svc + ihd2)) + } + _ => None, + } +} + #[derive(Clone, Copy)] enum Corner { Square, @@ -1307,8 +1328,17 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { } else { vec![(0.0, 0.0), (w - d, 0.0), (w, h / 2.0), (w - d, h), (0.0, h), (d, h / 2.0)] }; - let tx = if name == "homePlate" { 0.0 } else { d }; - let mut g = simple(b.poly(&pts).take(), Rect::new(tx, 0.0, w - d / 2.0, h), w, h); + // Text rectangles as presetShapeDefinitions give them: homePlate's runs to halfway + // along the point; chevron's lies between the notch and the point's base (x1..x2) + // and covers the whole width once those meet or cross (`?: dx x1 l`). + let text = if name == "homePlate" { + Rect::new(0.0, 0.0, w - d / 2.0, h) + } else if w - d > d { + Rect::new(d, 0.0, w - d, h) + } else { + Rect::new(0.0, 0.0, w, h) + }; + let mut g = simple(b.poly(&pts).take(), text, w, h); g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); g } @@ -1615,7 +1645,8 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { let n: usize = name.trim_start_matches("star").parse().unwrap_or(5); let v = a(0).clamp(0.0, 50000.0); let inner = v / 50000.0; - let mut g = simple(b.poly(&star(n, w, h, inner)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h); + let text = star_text_rect(n, w, h, inner).unwrap_or_else(|| full.inset(-ss * (0.5 - inner * 0.5) * 0.5)); + let mut g = simple(b.poly(&star(n, w, h, inner)).take(), text, w, h); g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); g } @@ -2306,6 +2337,19 @@ mod tests { } } + #[test] + fn star_text_rectangles_follow_the_standard() { + // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the + // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. + let g = build("star5", 144.0, 144.0, &[]).unwrap(); + let t = g.text_rect; + assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); + assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); + // star4 at 12500: the square through its inner points' 45° corners. + let g = build("star4", 144.0, 144.0, &[]).unwrap(); + assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); + } + #[test] fn stars_are_stretched_like_the_standards() { // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an diff --git a/crates/geom/src/squircle.rs b/crates/geom/src/squircle.rs new file mode 100644 index 00000000..8dedc972 --- /dev/null +++ b/crates/geom/src/squircle.rs @@ -0,0 +1,183 @@ +//! Rectangles with smoothed ("squircle") corners, as design tools offer next to the corner radius +//! (#286). A plain rounded corner joins the straight edge with a sudden change of curvature; a +//! smoothed corner starts curving earlier, along a cubic Bézier, and keeps a shorter circular arc +//! in the middle, so the outline flows into the edge. +//! +//! For a corner of radius `r` and smoothing `s` (0 = circular, 1 = no arc left), the corner takes +//! `p = (1 + s) · r` of each edge. The arc keeps `90° · (1 − s)` around the corner's diagonal and +//! each side of it is a cubic whose handles lie on the edge, so the curve is tangent to the edge +//! where it starts and to the arc where it meets it. When `p` would exceed half the shorter side, +//! the smoothing is reduced first, then the radius. The construction is our own derivation from +//! these constraints. + +/// The outline of a `w` x `h` rectangle with corners of radius `radius` smoothed by `smoothing` +/// (0..=1), as path data in the rectangle's own coordinates (`M`, `L`, `C`, `Z`; y down). +pub fn path_d(w: f64, h: f64, radius: f64, smoothing: f64) -> String { + let w = if w.is_finite() { w.max(0.0) } else { 0.0 }; + let h = if h.is_finite() { h.max(0.0) } else { 0.0 }; + let half = w.min(h) / 2.0; + let r = if radius.is_finite() { radius.clamp(0.0, half) } else { 0.0 }; + let mut s = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + if r <= 0.0 { + return format!("M 0 0 L {w} 0 L {w} {h} L 0 {h} Z"); + } + s = s.min(half / r - 1.0).max(0.0); + let c = corner(r, s); + // The top-right corner in local terms: `u` runs back along the top edge from the corner, + // `v` down the right edge. Each corner maps (u, v) to the rectangle by symmetry. + let corners: [&dyn Fn(f64, f64) -> (f64, f64); 4] = [ + &|u, v| (w - u, v), // top right: from the top edge to the right edge + &|u, v| (w - v, h - u), // bottom right: from the right edge to the bottom edge + &|u, v| (u, h - v), // bottom left: from the bottom edge to the left edge + &|u, v| (v, u), // top left: from the left edge to the top edge + ]; + let mut d = String::new(); + let pt = |f: &dyn Fn(f64, f64) -> (f64, f64), (u, v): (f64, f64)| { + let (x, y) = f(u, v); + format!("{} {}", round(x), round(y)) + }; + for (i, f) in corners.iter().enumerate() { + let segs = c.segments(); + if i == 0 { + d.push_str(&format!("M {}", pt(*f, segs[0]))); + } else { + d.push_str(&format!(" L {}", pt(*f, segs[0]))); + } + for k in 0..3 { + let b = 1 + k * 3; + d.push_str(&format!(" C {} {} {}", pt(*f, segs[b]), pt(*f, segs[b + 1]), pt(*f, segs[b + 2]))); + } + } + d.push_str(" Z"); + d +} + +fn round(v: f64) -> f64 { + (v * 1000.0).round() / 1000.0 + 0.0 +} + +/// One corner in (u, v) terms: distance back along the incoming edge, distance along the outgoing. +struct Corner { + p: f64, + a: f64, + b: f64, + c: f64, + d: f64, + r: f64, + arc: f64, +} + +fn corner(r: f64, s: f64) -> Corner { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + let p = (1.0 + s) * r; + let arc = FRAC_PI_2 * (1.0 - s); + // The arc's chord, and where the edge curve meets the arc. + let arc_chord = (arc / 2.0).sin() * r * std::f64::consts::SQRT_2; + let alpha = (FRAC_PI_2 - arc) / 2.0; + let p3_p4 = r * (alpha / 2.0).tan(); + let beta = FRAC_PI_4 * s; + let c = p3_p4 * beta.cos(); + let d = c * beta.tan(); + let b = ((p - arc_chord - c - d) / 3.0).max(0.0); + let a = 2.0 * b; + Corner { p, a, b, c, d, r, arc } +} + +impl Corner { + /// Ten points: the start on the incoming edge, then three cubics (control, control, end): + /// edge → arc start, the arc, arc end → outgoing edge. + fn segments(&self) -> [(f64, f64); 10] { + let Corner { p, a, b, c, d, r, arc } = *self; + // Edge curve into the arc: handles on the edge, ending where the arc starts. + let e0 = (p, 0.0); + let e1 = (p - a, 0.0); + let e2 = (p - a - b, 0.0); + let e3 = (p - a - b - c, d); + // The arc around the centre (r, r) in (u, v), symmetric about the corner's diagonal. + // Angles from +u towards +v: the corner itself is at 5π/4 from the centre; the arc runs + // from the incoming edge's side (larger angle) to the outgoing edge's side. + let start = std::f64::consts::FRAC_PI_4 * 5.0 + arc / 2.0; + let end = start - arc; + let at = |t: f64| (r + r * t.cos(), r + r * t.sin()); + let k = 4.0 / 3.0 * (arc / 4.0).tan() * r; + let a0 = at(start); + let a3 = at(end); + // Tangent of the arc travelled towards smaller angles: (sin t, −cos t). + let a1 = (a0.0 + k * start.sin(), a0.1 - k * start.cos()); + let a2 = (a3.0 - k * end.sin(), a3.1 + k * end.cos()); + // Mirror of the edge curve onto the outgoing edge (swap u and v), reversed. + let o1 = (e2.1, e2.0); + let o2 = (e1.1, e1.0); + let o3 = (e0.1, e0.0); + let _ = (e3, a0); + [e0, e1, e2, a0, a1, a2, a3, o1, o2, o3] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn numbers(d: &str) -> Vec { + d.split_whitespace().filter_map(|t| t.parse().ok()).collect() + } + + #[test] + fn no_radius_is_a_rectangle() { + assert_eq!(path_d(100.0, 50.0, 0.0, 0.6), "M 0 0 L 100 0 L 100 50 L 0 50 Z"); + } + + #[test] + fn edge_curve_meets_the_arc() { + for s in [0.0, 0.3, 0.6, 1.0] { + let c = corner(10.0, s); + let seg = c.segments(); + let e3 = (c.p - c.a - c.b - c.c, c.d); + // The arc starts on the circle and where the edge curve ends. + assert!((seg[3].0 - e3.0).abs() < 1e-6 && (seg[3].1 - e3.1).abs() < 1e-6, "s={s}: {:?} vs {:?}", seg[3], e3); + // The arc is symmetric about the diagonal u = v. + assert!((seg[3].0 - seg[6].1).abs() < 1e-9 && (seg[3].1 - seg[6].0).abs() < 1e-9); + } + } + + #[test] + fn zero_smoothing_is_a_circular_corner() { + let c = corner(10.0, 0.0); + let seg = c.segments(); + assert!((c.p - 10.0).abs() < 1e-9); + assert!((seg[3].0 - 10.0).abs() < 1e-9 && seg[3].1.abs() < 1e-9); + assert!((seg[6].0).abs() < 1e-9 && (seg[6].1 - 10.0).abs() < 1e-9); + } + + #[test] + fn smoothing_uses_more_of_the_edge() { + let d0 = numbers(&path_d(200.0, 100.0, 20.0, 0.0)); + let d6 = numbers(&path_d(200.0, 100.0, 20.0, 0.6)); + // The first point is where the top-right corner starts on the top edge. + assert_eq!((d0[0], d0[1]), (180.0, 0.0)); + assert_eq!((d6[0], d6[1]), (168.0, 0.0)); + } + + #[test] + fn stays_inside_and_reduces_smoothing_when_the_rectangle_is_small() { + for (w, h, r, s) in [(100.0, 40.0, 20.0, 1.0), (40.0, 40.0, 50.0, 0.8), (10.0, 300.0, 4.0, 0.6), (300.0, 120.0, 30.0, 0.6)] { + let n = numbers(&path_d(w, h, r, s)); + assert!(n.chunks(2).all(|p| p[0] >= -1e-6 && p[0] <= w + 1e-6 && p[1] >= -1e-6 && p[1] <= h + 1e-6), "{w}x{h} r{r} s{s}"); + // Corners never cross the middle of an edge. + assert!(n[0] >= w / 2.0 - 1e-6); + } + assert!(numbers(&path_d(f64::NAN, 10.0, f64::INFINITY, f64::NAN)).iter().all(|v| v.is_finite())); + } + + #[test] + fn four_symmetric_corners() { + let n = numbers(&path_d(120.0, 80.0, 16.0, 0.6)); + // 4 corners x 10 points, 2 numbers each. + assert_eq!(n.len(), 80); + let pts: Vec<(f64, f64)> = n.chunks(2).map(|p| (p[0], p[1])).collect(); + for &(x, y) in &pts { + let mirrored = (120.0 - x, 80.0 - y); + assert!(pts.iter().any(|q| (q.0 - mirrored.0).abs() < 0.01 && (q.1 - mirrored.1).abs() < 0.01), "{x},{y}"); + } + } +} diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index acdee999..d3035d51 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -234,6 +234,246 @@ pub fn is_supported(name: &str) -> bool { !adjust_defaults(name).is_empty() } +/// One DrawingML text-warp preset, in gallery order. +pub struct Preset { + /// `prstTxWarp` value, e.g. `textArchUp`. + pub id: &'static str, + /// Name shown on the Transform gallery tile. + pub label: &'static str, +} + +/// The 40 presets DeckCraft draws, Follow Path first, then Warp. +pub const CATALOG: &[Preset] = &[ + Preset { id: "textArchUp", label: "Arch Up" }, + Preset { id: "textArchDown", label: "Arch Down" }, + Preset { id: "textCircle", label: "Circle" }, + Preset { id: "textButton", label: "Button" }, + Preset { id: "textPlain", label: "Plain" }, + Preset { id: "textStop", label: "Stop" }, + Preset { id: "textTriangle", label: "Triangle" }, + Preset { id: "textTriangleInverted", label: "Inverted Triangle" }, + Preset { id: "textChevron", label: "Chevron" }, + Preset { id: "textChevronInverted", label: "Inverted Chevron" }, + Preset { id: "textRingInside", label: "Ring Inside" }, + Preset { id: "textRingOutside", label: "Ring Outside" }, + Preset { id: "textArchUpPour", label: "Arch Up Pour" }, + Preset { id: "textArchDownPour", label: "Arch Down Pour" }, + Preset { id: "textCirclePour", label: "Circle Pour" }, + Preset { id: "textButtonPour", label: "Button Pour" }, + Preset { id: "textCurveUp", label: "Curve Up" }, + Preset { id: "textCurveDown", label: "Curve Down" }, + Preset { id: "textCanUp", label: "Can Up" }, + Preset { id: "textCanDown", label: "Can Down" }, + Preset { id: "textWave1", label: "Wave 1" }, + Preset { id: "textWave2", label: "Wave 2" }, + Preset { id: "textDoubleWave1", label: "Double Wave 1" }, + Preset { id: "textWave4", label: "Wave 4" }, + Preset { id: "textInflate", label: "Inflate" }, + Preset { id: "textDeflate", label: "Deflate" }, + Preset { id: "textInflateBottom", label: "Inflate Bottom" }, + Preset { id: "textDeflateBottom", label: "Deflate Bottom" }, + Preset { id: "textInflateTop", label: "Inflate Top" }, + Preset { id: "textDeflateTop", label: "Deflate Top" }, + Preset { id: "textDeflateInflate", label: "Deflate Inflate" }, + Preset { id: "textDeflateInflateDeflate", label: "Deflate Inflate Deflate" }, + Preset { id: "textFadeRight", label: "Fade Right" }, + Preset { id: "textFadeLeft", label: "Fade Left" }, + Preset { id: "textFadeUp", label: "Fade Up" }, + Preset { id: "textFadeDown", label: "Fade Down" }, + Preset { id: "textSlantUp", label: "Slant Up" }, + Preset { id: "textSlantDown", label: "Slant Down" }, + Preset { id: "textCascadeUp", label: "Cascade Up" }, + Preset { id: "textCascadeDown", label: "Cascade Down" }, +]; + +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + /// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). #[derive(Clone, Copy, Debug)] enum Seg { diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index 2d18b13a..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -1,48 +1,5 @@ use super::*; -const ALL: &[&str] = &[ - "textPlain", - "textStop", - "textTriangle", - "textTriangleInverted", - "textChevron", - "textChevronInverted", - "textRingInside", - "textRingOutside", - "textArchUp", - "textArchDown", - "textCircle", - "textButton", - "textArchUpPour", - "textArchDownPour", - "textCirclePour", - "textButtonPour", - "textCurveUp", - "textCurveDown", - "textCanUp", - "textCanDown", - "textWave1", - "textWave2", - "textDoubleWave1", - "textWave4", - "textInflate", - "textDeflate", - "textInflateBottom", - "textDeflateBottom", - "textInflateTop", - "textDeflateTop", - "textDeflateInflate", - "textDeflateInflateDeflate", - "textFadeRight", - "textFadeLeft", - "textFadeUp", - "textFadeDown", - "textSlantUp", - "textSlantDown", - "textCascadeUp", - "textCascadeDown", -]; - const W: f64 = 420.0; const H: f64 = 200.0; @@ -77,9 +34,22 @@ fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f6 } } +#[test] +fn catalog_is_the_forty_drawn_presets() { + assert_eq!(CATALOG.len(), 40); + let mut seen = std::collections::BTreeSet::new(); + for p in CATALOG { + assert!(seen.insert(p.id), "duplicate {}", p.id); + assert!(!p.label.is_empty(), "{}", p.id); + assert!(is_supported(p.id), "{}", p.id); + assert_eq!(is_follow(p.id), matches!(p.id, "textArchUp" | "textArchDown" | "textCircle" | "textButton"), "{}", p.id); + } +} + #[test] fn every_preset_builds_finite_guides() { - for name in ALL { + for p in CATALOG { + let name = p.id; assert!(is_supported(name), "{name}"); let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}")); assert!(!w.bands.is_empty(), "{name}"); @@ -281,7 +251,7 @@ fn hostile_adjusts_and_boxes_stay_finite() { WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 }, WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 }, ]; - for name in ALL { + for name in CATALOG.iter().map(|p| p.id) { for adj in adjs { for r in rects { let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") }; @@ -307,3 +277,43 @@ fn hostile_adjusts_and_boxes_stay_finite() { } assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); } + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in CATALOG.iter().map(|p| p.id) { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 5f8941ea..37ef1f29 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -95,6 +95,8 @@ pub struct Decoder { pool: Option, in_flight: VecDeque, max_in_flight: usize, + /// Bytes the picture store may take ([`deckcraft_bitstream::budget`]). + memory_budget: u64, threads: usize, /// Draft mode: skip deblocking of non-reference pictures ([`Decoder::set_draft`]). draft: bool, @@ -178,6 +180,7 @@ impl Decoder { pool, in_flight: VecDeque::new(), max_in_flight: threads.max(1) + 2, + memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET, threads: workers, draft: false, } @@ -421,7 +424,34 @@ impl Decoder { Ok(()) } + /// Cap the bytes of decoded pictures (current, references, in flight) this decoder may hold. + pub fn set_memory_budget(&mut self, bytes: u64) { + self.memory_budget = bytes; + } + + /// Refuse an SPS whose pictures would not fit the memory budget, before anything is allocated. + fn check_budget(&self, sps: &Sps) -> Result<()> { + let chroma = sps.chroma_array_type(); + let shape = deckcraft_bitstream::budget::PictureShape { + width: u64::from(sps.width()), + height: u64::from(sps.height()), + shift_x: u32::from(chroma != 3), + shift_y: u32::from(chroma == 1), + chroma_planes: if chroma == 0 { 0 } else { 2 }, + bytes_per_sample: 1, + }; + let pictures = (sps.max_dpb_frames() + 1 + self.max_in_flight) as u64; + ensure!( + shape.fits(pictures, self.memory_budget), + "{}x{} pictures with {pictures} in the decoder exceed the memory budget", + sps.width(), + sps.height() + ); + Ok(()) + } + fn start_picture(&mut self, sh: &SliceHeader, sps: &Arc, pts: i64) -> Result<()> { + self.check_budget(sps)?; // Activate SPS; a resolution change flushes the DPB. let changed = match &self.active_sps { None => true, @@ -446,6 +476,8 @@ impl Decoder { } else { let max = sps.max_frame_num(); if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max { + // A gap is only legal when the SPS allows it (7.4.2.1.1), but a cut or lossy stream has + // them anyway: those are concealed too rather than dropping the picture. // frame_num gap (8.2.5.2): insert "non-existing" frames. Their samples are never used by // conforming streams; copy the latest decoded frame for concealment. let meta = Arc::new(output_meta(sps, pts, false)); diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index cb064335..5662e299 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -133,7 +133,27 @@ impl Dpb { make_frame: &mut dyn FnMut(u32) -> (FrameRef, Arc), poc_state: &mut dyn FnMut(u32), ) { + if max_frame_num == 0 { + return; + } let mut unused = (prev_ref_frame_num + 1) % max_frame_num; + // Only the last `max_num_ref_frames` "non-existing" frames can survive the sliding window, and + // inserting more than that evicts every earlier short-term reference. Skip straight to that tail + // (keeping the POC state walk for every missing frame_num) so a hostile gap of up to 65535 + // frames costs at most `max_num_ref_frames` frame allocations instead of one per missing frame. + let gap = frame_num.wrapping_add(max_frame_num).wrapping_sub(unused) % max_frame_num; + let keep = u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX); + let skip = gap.saturating_sub(keep); + if skip > 0 { + for e in self.entries.iter_mut().filter(|e| e.mark == RefMark::Short) { + e.mark = RefMark::Unused; + } + self.prune(); + for _ in 0..skip { + poc_state(unused); + unused = (unused + 1) % max_frame_num; + } + } let mut guard = 0; while unused != frame_num && guard < max_frame_num { self.sliding_window(unused, max_frame_num, max_num_ref_frames); @@ -418,3 +438,99 @@ impl Default for Dpb { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::picture::Frame; + + fn meta() -> Arc { + Arc::new(OutputMeta { + pts: 0, + key: false, + crop: (0, 0, 0, 0), + full_range: false, + colour_primaries: 2, + transfer_characteristics: 2, + matrix_coefficients: 2, + sar: (1, 1), + draft: false, + }) + } + + fn short(dpb: &mut Dpb, id: u32, frame_num: u32) { + dpb.entries.push(DpbEntry { + frame: Arc::new(Frame::new(id, 0, 1, 1)), + poc: 0, + frame_num, + long_term_frame_idx: 0, + mark: RefMark::Short, + needed_for_output: false, + non_existing: false, + meta: meta(), + }); + } + + /// Fill a gap and return (frames made, POC walk, reference frame_nums left in the DPB). + fn fill(dpb: &mut Dpb, prev: u32, cur: u32, max: u32, refs: usize) -> (u32, Vec, Vec<(u32, bool)>) { + let mut made = 0u32; + let mut walk = Vec::new(); + let mut make = |_f: u32| { + made += 1; + (Arc::new(Frame::new(1000 + made, 0, 1, 1)), meta()) + }; + let mut poc = |f: u32| walk.push(f); + dpb.fill_frame_num_gap(prev, cur, max, refs, &mut make, &mut poc); + let left = dpb.entries.iter().filter(|e| e.mark != RefMark::Unused).map(|e| (e.frame_num, e.non_existing)).collect(); + (made, walk, left) + } + + #[test] + fn huge_gap_makes_only_the_frames_that_stay_referenced() { + let mut dpb = Dpb::new(); + short(&mut dpb, 1, 0); + short(&mut dpb, 2, 1); + let (made, walk, left) = fill(&mut dpb, 1, 65535, 65536, 4); + assert_eq!(made, 4); + // The POC state still walks every missing frame_num, in order. + assert_eq!(walk.len(), 65533); + assert_eq!(walk.first(), Some(&2)); + assert_eq!(walk.last(), Some(&65534)); + assert_eq!(left, vec![(65531, true), (65532, true), (65533, true), (65534, true)]); + } + + #[test] + fn huge_gap_across_wraparound_matches_the_frame_by_frame_result() { + let mut dpb = Dpb::new(); + short(&mut dpb, 1, 65000); + let (made, walk, left) = fill(&mut dpb, 65000, 3, 65536, 2); + assert_eq!(made, 2); + assert_eq!(walk.len(), 538); + assert_eq!(walk.first(), Some(&65001)); + assert_eq!(walk.last(), Some(&2)); + assert_eq!(left, vec![(1, true), (2, true)]); + } + + #[test] + fn small_gap_keeps_earlier_references() { + let mut dpb = Dpb::new(); + short(&mut dpb, 1, 0); + short(&mut dpb, 2, 1); + let (made, walk, left) = fill(&mut dpb, 1, 4, 16, 4); + assert_eq!(made, 2); + assert_eq!(walk, vec![2, 3]); + assert_eq!(left, vec![(0, false), (1, false), (2, true), (3, true)]); + } + + #[test] + fn long_term_references_survive_a_huge_gap() { + let mut dpb = Dpb::new(); + short(&mut dpb, 1, 0); + if let Some(e) = dpb.entries.last_mut() { + e.mark = RefMark::Long; + } + let (made, _, left) = fill(&mut dpb, 0, 1000, 1024, 3); + assert!(made <= 3); + assert_eq!(left, vec![(0, false), (998, true), (999, true)]); + } +} diff --git a/crates/h264/src/params.rs b/crates/h264/src/params.rs index a7e35d73..461d316b 100644 --- a/crates/h264/src/params.rs +++ b/crates/h264/src/params.rs @@ -559,6 +559,12 @@ pub struct Pps { pub scaling: ScalingMatrices, } +/// `26 + minus26`, after `minus26` is inside −(26 + QpBdOffset) ..= 25. +fn qp_plus_26(minus26: i32, qp_bd: i32, name: &str) -> Result { + ensure!((-(26 + qp_bd)..=25).contains(&minus26), "{name} out of range"); + Ok(26 + minus26) +} + impl Pps { /// Parse a PPS. Needs the SPS table because the tail depends on chroma_format_idc. pub fn parse(rbsp: &[u8], spss: &[Option]) -> Result { @@ -610,8 +616,11 @@ impl Pps { ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range"); let weighted_pred = r.read_flag()?; let weighted_bipred_idc = r.read_bits(2)?; - let pic_init_qp = 26 + r.read_se()?; - let pic_init_qs = 26 + r.read_se()?; + // pic_init_qp_minus26 is −(26 + QpBdOffsetY) ..= 25. Check that before adding 26: a hostile + // Exp-Golomb value overflows the addition in debug builds. + let qp_bd = 6 * sps.bit_depth_luma.saturating_sub(8) as i32; + let pic_init_qp = qp_plus_26(r.read_se()?, qp_bd, "pic_init_qp_minus26")?; + let pic_init_qs = qp_plus_26(r.read_se()?, qp_bd, "pic_init_qs_minus26")?; let chroma_qp_index_offset = r.read_se()?; ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range"); let deblocking_filter_control_present = r.read_flag()?; @@ -751,4 +760,64 @@ mod tests { assert!(Sps::parse(&sps_with(10, Some([0x8000_0000, 0, 0, 0]))).is_err()); assert!(Sps::parse(&sps_with(10, Some([1, 1, 1, 1]))).is_ok()); } + + fn pps_bits(qp_minus26: i32, qs_minus26: i32) -> Vec { + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + w.write_ue(0); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 2); + w.write_se(qp_minus26); + w.write_se(qs_minus26); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + w.finish() + } + + fn sps_table() -> Vec> { + vec![Sps::parse(&sps_with(10, None)).ok()] + } + + #[test] + fn pic_init_qp_is_checked_before_adding_26() { + let table = sps_table(); + assert_eq!(Pps::parse(&pps_bits(0, 0), &table).unwrap().pic_init_qp, 26); + assert_eq!(Pps::parse(&pps_bits(25, 0), &table).unwrap().pic_init_qp, 51); + assert_eq!(Pps::parse(&pps_bits(-26, 0), &table).unwrap().pic_init_qp, 0); + assert!(Pps::parse(&pps_bits(26, 0), &table).is_err()); + assert!(Pps::parse(&pps_bits(-27, 0), &table).is_err()); + assert!(Pps::parse(&pps_bits(i32::MAX, 0), &table).is_err()); + assert!(Pps::parse(&pps_bits(0, i32::MAX), &table).is_err()); + } + + #[test] + fn slice_qp_addition_does_not_overflow() { + let table = sps_table(); + let pps = Pps::parse(&pps_bits(0, 0), &table).unwrap(); + let sps = table[0].clone().unwrap(); + let nal = crate::slice::NalHeader { nal_ref_idc: 0, nal_unit_type: crate::slice::nal_type::SLICE }; + let rbsp = |delta: i32| { + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(2); // I + w.write_ue(0); + w.write_bits(0, sps.log2_max_frame_num); + w.write_se(delta); + w.rbsp_trailing(); + w.finish() + }; + let parse = |delta: i32| crate::slice::SliceHeader::parse(&rbsp(delta), nal, |_| Ok((&pps, table[0].as_ref().unwrap()))); + let (sh, _, _) = parse(0).unwrap(); + assert_eq!(sh.qp(&pps), 26); + assert!(parse(26).is_err()); + assert!(parse(i32::MAX).is_err()); + } } diff --git a/crates/h264/src/slice.rs b/crates/h264/src/slice.rs index f53edbfd..e1cad290 100644 --- a/crates/h264/src/slice.rs +++ b/crates/h264/src/slice.rs @@ -144,7 +144,7 @@ pub struct SliceHeader { impl SliceHeader { pub fn qp(&self, pps: &Pps) -> i32 { - pps.pic_init_qp + self.slice_qp_delta + pps.pic_init_qp.saturating_add(self.slice_qp_delta) } pub fn has_mmco5(&self) -> bool { self.mmcos.iter().any(|m| m.op == 5) @@ -315,7 +315,7 @@ impl SliceHeader { ensure!(cabac_init_idc <= 2, "cabac_init_idc out of range"); } let slice_qp_delta = r.read_se()?; - let qp = pps.pic_init_qp + slice_qp_delta; + let qp = pps.pic_init_qp.checked_add(slice_qp_delta).ok_or_else(|| crate::Error::Invalid("slice QP out of range".into()))?; ensure!((0..=51).contains(&qp), "slice QP {qp} out of range"); let mut sp_for_switch = false; let mut slice_qs_delta = 0; diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index b0e0d5d0..ec5f601d 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -539,3 +539,74 @@ fn poc_type1_reordering() { } } } + +/// One IDR picture of PCM macroblocks. +fn idr_stream(max_refs: u32) -> Vec { + let mut seed = 5u32; + let mbs: Vec>> = (0..NMB).map(|_| Some(random_mb(&mut seed))).collect(); + let mut stream = sps(66, max_refs, false, PocType::T2); + stream.extend(pps(false, false)); + stream.extend(slice_nal(&Pic { idr: true, frame_num: 0, ref_idc: 3, mbs, ..Default::default() }, false)); + stream +} + +fn decodes_within(stream: &[u8], budget: u64) -> bool { + let mut dec = Decoder::with_threads(1); + dec.set_memory_budget(budget); + let Ok(mut out) = dec.decode(stream, 0) else { return false }; + out.extend(dec.flush()); + dec.take_error().is_none() && out.len() == 1 +} + +#[test] +fn picture_store_is_checked_against_the_memory_budget() { + let stream = idr_stream(4); + let sps_nal = sps(66, 4, false, PocType::T2); + let parsed = crate::params::Sps::parse(&deckcraft_bitstream::unescape_rbsp(&sps_nal[5..])).unwrap(); + // 4:2:0, 8-bit: luma plus two quarter-size chroma planes per picture; the DPB, the current + // picture and threads + 2 in flight. + let one = (NMB * 256 + 2 * NMB * 64) as u64; + let pictures = (parsed.max_dpb_frames() + 1 + 3) as u64; + assert!(decodes_within(&stream, pictures * one)); + assert!(!decodes_within(&stream, pictures * one - 1)); + assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET)); +} + +#[test] +fn largest_accepted_h264_size_is_over_the_default_budget() { + // 1024 x 1024 macroblocks (16384 x 16384) with 16 references and 3 in flight. + let shape = + deckcraft_bitstream::budget::PictureShape { width: 16384, height: 16384, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 1 }; + assert!(!shape.fits(16 + 1 + 3, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET)); +} + +fn gap_stream(gaps: bool) -> Vec { + let mut seed = 7u32; + let a: Vec>> = (0..NMB).map(|_| Some(random_mb(&mut seed))).collect(); + let mut stream = sps(66, 2, gaps, PocType::T2); + stream.extend(pps(false, false)); + stream.extend(slice_nal(&Pic { idr: true, frame_num: 0, ref_idc: 3, mbs: a, ..Default::default() }, false)); + // frame_num 1..=9 are missing. + stream.extend(slice_nal(&Pic { frame_num: 10, ref_idc: 2, mbs: vec![None; NMB], ..Default::default() }, false)); + stream +} + +#[test] +fn frame_num_gap_is_concealed_even_when_the_sps_forbids_gaps() { + // A cut or lossy stream: the gap is filled (bounded) instead of dropping the picture. + let mut dec = Decoder::with_threads(1); + let mut out = dec.decode(&gap_stream(false), 0).unwrap(); + out.extend(dec.flush()); + assert_eq!(out.len(), 2); + assert_eq!(dec.stats().frame_num_gaps, 1); +} + +#[test] +fn frame_num_gap_is_filled_when_the_sps_allows_gaps() { + let mut dec = Decoder::with_threads(1); + let mut out = dec.decode(&gap_stream(true), 0).unwrap(); + out.extend(dec.flush()); + assert!(dec.take_error().is_none()); + assert_eq!(out.len(), 2); + assert_eq!(dec.stats().frame_num_gaps, 1); +} diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index cd5450fd..9038c3ad 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -88,6 +88,8 @@ pub struct Decoder { pool: Option, in_flight: VecDeque, max_in_flight: usize, + /// Bytes the picture store may take ([`deckcraft_bitstream::budget`]). + memory_budget: u64, draft: bool, } @@ -142,6 +144,7 @@ impl Decoder { pool, in_flight: VecDeque::new(), max_in_flight: threads.max(1) + 2, + memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET, draft: false, } } @@ -372,7 +375,34 @@ impl Decoder { Ok(()) } + /// Cap the bytes of decoded pictures (current, references, in flight) this decoder may hold. + pub fn set_memory_budget(&mut self, bytes: u64) { + self.memory_budget = bytes; + } + + /// Refuse an SPS whose pictures would not fit the memory budget, before anything is allocated. + fn check_budget(&self, sps: &Sps) -> Result<()> { + let chroma = sps.chroma_array_type(); + let shape = deckcraft_bitstream::budget::PictureShape { + width: u64::from(sps.width), + height: u64::from(sps.height), + shift_x: u32::from(sps.sub_width_c() == 2), + shift_y: u32::from(sps.sub_height_c() == 2), + chroma_planes: if chroma == 0 { 0 } else { 2 }, + bytes_per_sample: 2, + }; + let pictures = u64::from(sps.max_dec_pic_buffering) + 1 + self.max_in_flight as u64; + ensure!( + shape.fits(pictures, self.memory_budget), + "{}x{} pictures with {pictures} in the decoder exceed the memory budget", + sps.width, + sps.height + ); + Ok(()) + } + fn start_picture(&mut self, sh: &SliceHeader, sps: &Arc, pps: &Arc, pts: i64) -> Result<()> { + self.check_budget(sps)?; ensure!(sh.first_slice_segment_in_pic && !sh.dependent, "invalid first slice segment"); let hdr = sh.nal; let changed = match &self.active_sps { diff --git a/crates/hevc/src/params.rs b/crates/hevc/src/params.rs index 0044a6ac..d6eda61b 100644 --- a/crates/hevc/src/params.rs +++ b/crates/hevc/src/params.rs @@ -726,7 +726,11 @@ impl Pps { let num_ref_idx_l0_default = r.read_ue_plus(1)?; let num_ref_idx_l1_default = r.read_ue_plus(1)?; ensure!(num_ref_idx_l0_default <= 15 && num_ref_idx_l1_default <= 15, "num_ref_idx_default_active out of range"); - let init_qp = 26 + r.read_se()?; + // init_qp_minus26 is −(26 + QpBdOffsetY) ..= 25. Bit depth is not known here (no SPS), so the + // widest legal window is 16-bit (QpBdOffsetY = 48). Reject before adding 26. + let init_qp_minus26 = r.read_se()?; + ensure!((-74..=25).contains(&init_qp_minus26), "init_qp_minus26 out of range"); + let init_qp = 26 + init_qp_minus26; let constrained_intra_pred = r.read_flag()?; let transform_skip = r.read_flag()?; let cu_qp_delta_enabled = r.read_flag()?; @@ -999,4 +1003,48 @@ mod tests { assert!(StRps::parse(&mut BitReader::new(&b), 0, &[], 1).is_err(), "ue {ue:#x}"); } } + + fn pps_bits(init_qp_minus26: i32) -> Vec { + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + w.write_bits(0, 3); + w.write_bit(false); + w.write_bit(false); + w.write_ue(0); + w.write_ue(0); + w.write_se(init_qp_minus26); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.write_se(0); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.write_bit(true); // loop filter across slices + w.write_bit(false); // deblocking + w.write_bit(false); // scaling list + w.write_bit(false); + w.write_ue(0); // log2_parallel_merge_level_minus2 + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + w.finish() + } + + #[test] + fn init_qp_is_checked_before_adding_26() { + assert_eq!(Pps::parse(&pps_bits(0)).unwrap().init_qp, 26); + assert_eq!(Pps::parse(&pps_bits(25)).unwrap().init_qp, 51); + assert_eq!(Pps::parse(&pps_bits(-74)).unwrap().init_qp, -48); + assert!(Pps::parse(&pps_bits(26)).is_err()); + assert!(Pps::parse(&pps_bits(-75)).is_err()); + assert!(Pps::parse(&pps_bits(i32::MAX)).is_err()); + } } diff --git a/crates/hevc/src/slice.rs b/crates/hevc/src/slice.rs index c7bd1e39..d6fc439a 100644 --- a/crates/hevc/src/slice.rs +++ b/crates/hevc/src/slice.rs @@ -339,7 +339,7 @@ impl SliceHeader { sh.max_num_merge_cand = 5 - five_minus; } sh.qp_delta = r.read_se()?; - let qp = pps.init_qp + sh.qp_delta; + let qp = pps.init_qp.checked_add(sh.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8); ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range"); if pps.slice_chroma_qp_offsets_present { @@ -368,7 +368,8 @@ impl SliceHeader { let len = r.read_ue_plus(1)?; ensure!(len <= 32, "offset_len_minus1 out of range"); for _ in 0..n { - sh.entry_points.push(r.read_bits(len)? + 1); + let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?; + sh.entry_points.push(off); } } } @@ -391,7 +392,9 @@ fn parse_pwt(r: &mut BitReader, sh: &SliceHeader, sps: &Sps) -> Result SliceDecoder<'a> { let sh = &job.sh; let (sps, pps) = (&*job.sps, &*job.pps); ensure!(sh.data_offset < job.rbsp.len(), "empty slice segment data"); - let slice_qp = pps.init_qp + sh.qp_delta; + let slice_qp = pps.init_qp.checked_add(sh.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; let init_type = match sh.slice_type { SliceType::I => 0, SliceType::P => { diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 2d5b9fcf..dc225571 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -618,3 +618,29 @@ fn slices_without_tiles() { ]; run("slices", None, false, segs); } + +/// Decode with a memory budget of `budget` bytes; true if every picture came out without error. +fn decodes_within(stream: &[u8], budget: u64) -> bool { + let mut dec = Decoder::with_threads(1); + dec.set_memory_budget(budget); + let mut pics = match dec.decode(stream, 0) { + Ok(p) => p, + Err(_) => return false, + }; + pics.extend(dec.flush()); + dec.take_error().is_none() && pics.len() == 5 +} + +#[test] +fn picture_store_is_checked_against_the_memory_budget() { + let (stream, _) = sequence(&None, true, whole(WC * HC)); + let sps_nal = sps(true); + let parsed = crate::params::Sps::parse(&deckcraft_bitstream::unescape_rbsp(&sps_nal[6..])).unwrap(); + // 96x64 4:2:0 in 16-bit samples; the decoder holds the DPB, the current picture and up to + // threads + 2 in flight. + let one = (W * H + 2 * (W / 2) * (H / 2)) as u64 * 2; + let pictures = u64::from(parsed.max_dec_pic_buffering) + 1 + 3; + assert!(decodes_within(&stream, pictures * one)); + assert!(!decodes_within(&stream, pictures * one - 1)); + assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET)); +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index 4f132941..71580c60 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1,7 +1,7 @@ //! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. use crate::bytes::{BoxBuf, Cur, FourCc, boxes}; -use crate::error::{Error, Result}; +use crate::error::{Error, Result, invalid}; use deckcraft_bitstream::BitReader; /// One sample description (an entry of `stsd`). @@ -606,8 +606,9 @@ pub struct PcmConfig { impl PcmConfig { /// Bytes per interleaved audio frame (all channels). - pub fn bytes_per_frame(&self) -> u32 { - (self.bits as u32).div_ceil(8) * self.channels.max(1) + /// `None` when `channels` times the bytes per sample does not fit in `u32`. + pub fn bytes_per_frame(&self) -> Option { + u32::from(self.bits).div_ceil(8).checked_mul(self.channels.max(1)) } } @@ -888,6 +889,9 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc let _size_of_struct = c.u32()?; a.sample_rate = c.f64()?; a.channels = c.u32()?; + if a.channels > u32::from(u16::MAX) { + return invalid("channel count out of range"); + } let _always = c.u32()?; a.const_bits_per_channel = c.u32()?; a.lpcm_flags = c.u32()?; @@ -931,7 +935,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc }, b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), + b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)?), b"ipcm" | b"fpcm" => { let mut pcm = PcmConfig { bits: a.sample_size, @@ -948,6 +952,9 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc pcm.big_endian = flags & 1 == 0; pcm.bits = c.u8()? as u16; } + if pcm.bytes_per_frame().is_none() { + return invalid("PCM frame size overflows"); + } CodecConfig::Pcm(pcm) } _ => unknown(), @@ -957,11 +964,11 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) } -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { +fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> Result { let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; match f { b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; + p.bits = u16::try_from(a.const_bits_per_channel).map_err(|_| Error::Invalid("const_bits_per_channel out of range".into()))?; p.float = a.lpcm_flags & 1 != 0; p.big_endian = a.lpcm_flags & 2 != 0; p.signed = a.lpcm_flags & 4 != 0 || p.float; @@ -992,7 +999,10 @@ fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { } _ => {} } - p + if p.bytes_per_frame().is_none() { + return invalid("PCM frame size overflows"); + } + Ok(p) } fn parse_btrt(p: &[u8]) -> Result { @@ -1360,7 +1370,7 @@ impl SampleEntry { b.u32(p.bits as u32); let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8; b.u32(flags); - b.u32(p.bytes_per_frame()); + b.u32(p.bytes_per_frame().unwrap_or(0)); b.u32(1); } else { b.u16(version); @@ -1536,6 +1546,40 @@ mod tests { assert_eq!(roundtrip(&e, true, b"soun"), e); } + fn qt_v2_lpcm(channels: u32, bits: u32) -> Vec { + let mut p = vec![0; 6]; + let u16b = |p: &mut Vec, v: u16| p.extend(v.to_be_bytes()); + let u32b = |p: &mut Vec, v: u32| p.extend(v.to_be_bytes()); + u16b(&mut p, 1); + u16b(&mut p, 2); + u16b(&mut p, 0); + u32b(&mut p, 0); + u16b(&mut p, 2); + u16b(&mut p, 16); + u16b(&mut p, 0); + u16b(&mut p, 0); + u32b(&mut p, 65536); + u32b(&mut p, 72); + p.extend(48000.0f64.to_be_bytes()); + u32b(&mut p, channels); + u32b(&mut p, 0); + u32b(&mut p, bits); + u32b(&mut p, 0); + u32b(&mut p, 0); + u32b(&mut p, 1); + p + } + + #[test] + fn pcm_frame_size_rejects_a_hostile_channel_count() { + assert_eq!(PcmConfig { bits: 16, channels: 2, sample_rate: 48000.0, ..Default::default() }.bytes_per_frame(), Some(4)); + assert!(PcmConfig { bits: 16, channels: u32::MAX, ..Default::default() }.bytes_per_frame().is_none()); + assert!(parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(u32::MAX, 16), FourCc(*b"soun"), true).is_err()); + assert!(parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(2, u32::MAX), FourCc(*b"soun"), true).is_err()); + let ok = parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(2, 16), FourCc(*b"soun"), true).unwrap(); + assert!(matches!(ok.codec, CodecConfig::Pcm(p) if p.bytes_per_frame() == Some(4))); + } + #[test] fn pcm_entries() { for (bits, float, be, qt) in [ diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs index dc9e7647..ee8eb473 100644 --- a/crates/isobmff/src/demux.rs +++ b/crates/isobmff/src/demux.rs @@ -699,13 +699,14 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result< if let Some(p) = pcm { t.pcm_chunked = true; let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { + let bpf = p.bytes_per_frame().ok_or_else(|| Error::Invalid("PCM frame size overflows".into()))?; + let frame_bytes = |frames: u64| -> Option { if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 + frames.checked_div(u64::from(a.samples_per_packet))?.checked_mul(u64::from(a.bytes_per_frame)) } else if const_size > 1 { - frames * const_size as u64 + frames.checked_mul(u64::from(const_size)) } else { - frames * p.bytes_per_frame() as u64 + frames.checked_mul(u64::from(bpf)) } }; if chunks.len() > MAX_SAMPLES { @@ -721,7 +722,7 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result< continue; } let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; + let size = frame_bytes(spc as u64).ok_or_else(|| Error::Invalid("PCM sample size overflows".into()))?.min(u64::from(u32::MAX)) as u32; samples.push(Sample { offset: off, size, diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index f3a062b5..bf9a4cbf 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -498,7 +498,10 @@ fn validate_track(cfg: &TrackConfig) -> Result> { if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 { return Err(Error::Mux("PCM track timescale must equal the sample rate".into())); } - return Ok(Some(p.bytes_per_frame())); + let Some(n) = p.bytes_per_frame() else { + return Err(Error::Mux("PCM frame size overflows".into())); + }; + return Ok(Some(n)); } Ok(None) } diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs index 7d5241b4..66dfe177 100644 --- a/crates/matroska/src/demux.rs +++ b/crates/matroska/src/demux.rs @@ -354,6 +354,15 @@ fn parse_block( Ok(Some((track, rel, flags, frames))) } +/// Cluster timestamps are unsigned and must fit in `i64` before a signed block offset is added. +fn cluster_timestamp(raw: u64) -> Result { + i64::try_from(raw).map_err(|_| Error::Invalid("cluster timestamp out of range".into())) +} + +fn block_timestamp(cluster_ts: i64, rel: i16) -> Result { + cluster_ts.checked_add(i64::from(rel)).ok_or_else(|| Error::Invalid("block timestamp overflow".into())) +} + /// Parse one SimpleBlock or BlockGroup element. fn parse_block_elem( io: &mut Io<'_, S>, @@ -369,7 +378,7 @@ fn parse_block_elem( Ok(Some(BlockRef { track, block_offset: h.pos, - ts: cluster_ts + rel as i64, + ts: block_timestamp(cluster_ts, rel)?, frames, block_duration: None, keyframe: flags & 0x80 != 0, @@ -398,7 +407,7 @@ fn parse_block_elem( Ok(Some(BlockRef { track, block_offset: h.pos, - ts: cluster_ts + rel as i64, + ts: block_timestamp(cluster_ts, rel)?, frames, block_duration: dur, keyframe: !has_ref, @@ -492,7 +501,7 @@ fn scan_cluster( } let ce = c.end(lim); match c.id { - TIMESTAMP => ts = ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?) as i64, + TIMESTAMP => ts = cluster_timestamp(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?))?, SIMPLE_BLOCK | BLOCK_GROUP => { if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { blocks.push(b); @@ -1104,7 +1113,7 @@ impl Demuxer { self.pos = he; match h.id { TIMESTAMP => { - let v = ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?) as i64; + let v = cluster_timestamp(ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?))?; self.cluster = Some(InCluster { ts: v, end: c.end }); } SIMPLE_BLOCK | BLOCK_GROUP => { diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs index 081759b2..698a2cb5 100644 --- a/crates/matroska/src/meta.rs +++ b/crates/matroska/src/meta.rs @@ -241,7 +241,10 @@ pub(crate) fn parse_cues(d: &[u8], out: &mut Vec) { } } -fn parse_chapter(d: &[u8]) -> Chapter { +/// Deepest nesting of `ChapterAtom` / `SimpleTag` we follow. Deeper children are ignored so a hostile file cannot overflow the stack. +const MAX_NESTING_DEPTH: usize = 32; + +fn parse_chapter(d: &[u8], depth: usize) -> Chapter { let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() }; for e in Children::new(d) { @@ -268,7 +271,7 @@ fn parse_chapter(d: &[u8]) -> Chapter { } c.displays.push(disp); } - CHAPTER_ATOM => c.children.push(parse_chapter(e.data)), + CHAPTER_ATOM if depth + 1 < MAX_NESTING_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)), _ => {} } } @@ -284,7 +287,7 @@ pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0, EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0, EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0, - CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data)), + CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)), _ => {} } } @@ -292,7 +295,7 @@ pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { } } -fn parse_simple_tag(d: &[u8]) -> SimpleTag { +fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag { let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() }; for e in Children::new(d) { match e.id { @@ -301,7 +304,7 @@ fn parse_simple_tag(d: &[u8]) -> SimpleTag { TAG_DEFAULT => t.default = uint(e.data) != 0, TAG_STRING => t.string = Some(string(e.data)), TAG_BINARY => t.binary = Some(e.data.to_vec()), - SIMPLE_TAG => t.children.push(parse_simple_tag(e.data)), + SIMPLE_TAG if depth + 1 < MAX_NESTING_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)), _ => {} } } @@ -326,7 +329,7 @@ pub(crate) fn parse_tags(d: &[u8], out: &mut Vec) { } } } - SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data)), + SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)), _ => {} } } @@ -549,3 +552,61 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ebml::{write_id, write_size}; + + /// `levels` elements with id `id`, each holding the next; the innermost is empty. + fn nested(id: u32, levels: usize) -> Vec { + let mut inner = Vec::new(); + for _ in 0..levels { + let mut e = Vec::new(); + write_id(&mut e, id); + write_size(&mut e, inner.len() as u64, 1); + e.extend_from_slice(&inner); + inner = e; + } + inner + } + + fn chapter_depth(c: &Chapter) -> usize { + 1 + c.children.iter().map(chapter_depth).max().unwrap_or(0) + } + + fn tag_depth(t: &SimpleTag) -> usize { + 1 + t.children.iter().map(tag_depth).max().unwrap_or(0) + } + + fn chapters_of(levels: usize) -> usize { + let mut edition = Vec::new(); + write_id(&mut edition, EDITION_ENTRY); + let atoms = nested(CHAPTER_ATOM, levels); + write_size(&mut edition, atoms.len() as u64, 1); + edition.extend_from_slice(&atoms); + let mut out = Vec::new(); + parse_chapters(&edition, &mut out); + chapter_depth(&out[0].chapters[0]) + } + + #[test] + fn chapter_nesting_is_bounded() { + assert_eq!(chapters_of(MAX_NESTING_DEPTH), MAX_NESTING_DEPTH); + assert_eq!(chapters_of(MAX_NESTING_DEPTH + 50), MAX_NESTING_DEPTH); + } + + #[test] + fn simple_tag_nesting_is_bounded() { + for (levels, expected) in [(MAX_NESTING_DEPTH, MAX_NESTING_DEPTH), (MAX_NESTING_DEPTH + 50, MAX_NESTING_DEPTH)] { + let mut tag = Vec::new(); + write_id(&mut tag, TAG); + let simple = nested(SIMPLE_TAG, levels); + write_size(&mut tag, simple.len() as u64, 1); + tag.extend_from_slice(&simple); + let mut out = Vec::new(); + parse_tags(&tag, &mut out); + assert_eq!(tag_depth(&out[0].simple[0]), expected); + } + } +} diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs index e267f8c4..0ed86e4c 100644 --- a/crates/matroska/src/synthetic_tests.rs +++ b/crates/matroska/src/synthetic_tests.rs @@ -213,3 +213,42 @@ fn synthetic_file() { assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); assert!(d.file().indexed); } + +fn one_block(ts: u64, rel: i16) -> Vec { + let mut f = Vec::new(); + let mut h = Vec::new(); + el_str(&mut h, DOC_TYPE, "matroska"); + el(&mut f, EBML, &h); + write_id(&mut f, SEGMENT); + write_unknown_size(&mut f, 8); + let mut info = Vec::new(); + el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000); + el(&mut f, INFO, &info); + let mut v = Vec::new(); + el_uint(&mut v, TRACK_NUMBER, 1); + el_uint(&mut v, TRACK_UID, 1); + el_uint(&mut v, TRACK_TYPE, 1); + el_str(&mut v, CODEC_ID, "V_UNCOMPRESSED"); + let mut tr = Vec::new(); + el(&mut tr, TRACK_ENTRY, &v); + el(&mut f, TRACKS, &tr); + write_id(&mut f, CLUSTER); + write_unknown_size(&mut f, 8); + el_uint(&mut f, TIMESTAMP, ts); + el(&mut f, SIMPLE_BLOCK, &block(1, rel, 0x80, &[0])); + f +} + +#[test] +fn block_timestamp_addition_does_not_overflow() { + let bytes = one_block(1000, 5); + let d = Demuxer::from_slice(&bytes).unwrap(); + let p: Vec = d.map(|p| p.unwrap()).collect(); + assert_eq!(p[0].pts, 1005); + let edge = one_block(i64::MAX as u64, 0); + let d = Demuxer::from_slice(&edge).unwrap(); + let p: Vec = d.map(|p| p.unwrap()).collect(); + assert_eq!(p[0].pts, i64::MAX); + assert!(Demuxer::from_slice(&one_block(i64::MAX as u64, 1)).is_err()); + assert!(Demuxer::from_slice(&one_block(u64::MAX, 0)).is_err()); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index f3686545..cc43b4ee 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -55,16 +55,24 @@ impl Remote { Err(last) } - fn roundtrip(&mut self, line: &str) -> std::io::Result { - if self.conn.is_none() { + /// Write one request line. A failure here means the app never got a complete line, so retrying is safe. + /// A connection the app has already closed (it restarted since the last call) is replaced before writing. + fn send(&mut self, line: &str) -> std::io::Result<()> { + if self.conn.as_ref().is_none_or(|(_, writer)| is_closed(writer)) { self.reconnect()?; } - let Some((reader, writer)) = self.conn.as_mut() else { + let Some((_, writer)) = self.conn.as_mut() else { + return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(format!("{line}\n").as_bytes())?; + writer.flush() + } + + /// Read the reply to a request that was already sent. + fn receive(&mut self) -> std::io::Result { + let Some((reader, _)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); }; - writer.write_all(line.as_bytes())?; - writer.write_all(b"\n")?; - writer.flush()?; let mut reply = String::new(); if reader.read_line(&mut reply)? == 0 { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); @@ -73,22 +81,39 @@ impl Remote { } } +/// True when the peer has closed or reset `stream`, or sent bytes nobody asked for. Between calls nothing +/// should be waiting, so only "would block" means the connection is still good. +fn is_closed(stream: &TcpStream) -> bool { + if stream.set_nonblocking(true).is_err() { + return true; + } + let mut byte = [0u8; 1]; + let open = matches!(stream.peek(&mut byte), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock); + stream.set_nonblocking(false).is_err() || !open +} + impl Backend for Remote { fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); - // One retry with a fresh connection (the app may have restarted). - let reply = match self.roundtrip(&line) { - Ok(r) => r, - Err(_) => { + // One retry with a fresh connection (the app may have restarted), but only while the request + // was not sent. Once it is out, a lost reply leaves the outcome unknown and a resend could + // run an insert/delete/undo twice, so report that instead. + if self.send(&line).is_err() { + self.conn = None; + self.send(&line).map_err(|e| { self.conn = None; - self.roundtrip(&line).map_err(|e| { - self.conn = None; - format!("DeckCraft app at {} is not reachable: {e}", self.addr) - })? - } - }; + format!("DeckCraft app at {} is not reachable: {e}", self.addr) + })?; + } + let reply = self.receive().map_err(|e| { + self.conn = None; + format!( + "lost the reply from the DeckCraft app at {} ({e}); the command may or may not have run, check the document before retrying", + self.addr + ) + })?; let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) @@ -105,3 +130,75 @@ impl Backend for Remote { format!("connected to the DeckCraft app at {}", self.addr) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn lost_reply_is_not_resent() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr").to_string(); + let executed = Arc::new(AtomicUsize::new(0)); + let count = Arc::clone(&executed); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + let n = count.fetch_add(1, Ordering::SeqCst) + 1; + if n > 1 { + let mut w = &stream; + let _ = w.write_all(b"{\"id\":2,\"ok\":true,\"result\":null}\n"); + } + // The first request is executed, then the connection drops without a reply. + } + }); + let mut remote = Remote::connect(&addr).expect("connect"); + let err = remote.call("engine.execute", json!({})).expect_err("lost reply must be an error"); + assert!(err.contains("may or may not have run"), "{err}"); + assert_eq!(executed.load(Ordering::SeqCst), 1, "the command must not be replayed"); + // The next call opens a fresh connection and works. + assert!(remote.call("document.inspect", json!({})).is_ok()); + assert_eq!(executed.load(Ordering::SeqCst), 2); + } + + #[test] + fn connection_closed_before_the_call_is_retried() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr").to_string(); + let (closed_tx, closed_rx) = std::sync::mpsc::channel(); + let executed = Arc::new(AtomicUsize::new(0)); + let count = Arc::clone(&executed); + std::thread::spawn(move || { + let mut first = true; + for stream in listener.incoming().flatten() { + if first { + // The app restarts: the first connection closes before any request arrives. + first = false; + drop(stream); + let _ = closed_tx.send(()); + continue; + } + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + count.fetch_add(1, Ordering::SeqCst); + let mut w = &stream; + let _ = w.write_all(b"{\"id\":1,\"ok\":true,\"result\":null}\n"); + } + }); + let mut remote = Remote::connect(&addr).expect("connect"); + closed_rx.recv().expect("server closed the first connection"); + std::thread::sleep(Duration::from_millis(200)); + assert!(remote.call("engine.execute", json!({})).is_ok(), "a request that never left must be sent on a fresh connection"); + assert_eq!(executed.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 512e7cc4..bd1faf09 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,6 +1,9 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. use deckcraft_engine::{Mods, PointerEvent, PointerKind, Session, ToolKind}; +use std::sync::Arc; + +use deckcraft_engine::file_access::{self, AutomationRoots}; use serde_json::{Value, json}; use crate::backend::Backend; @@ -8,6 +11,7 @@ use crate::backend::Backend; /// Headless backend: a [`Session`] rendering slides on the CPU. pub struct Headless { pub session: Session, + roots: Option>, } impl Default for Headless { @@ -32,6 +36,7 @@ pub fn pointer_kind(k: &str) -> Option { "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, "tripleclick" => PointerKind::TripleClick, + "contextclick" | "rightclick" => PointerKind::ContextClick, _ => return None, }) } @@ -58,12 +63,19 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option { impl Headless { pub fn new() -> Self { - Self { session: Session::new() } + Self { session: Session::new(), roots: None } } /// A session with a fresh presentation. pub fn with_document() -> Self { - Self { session: Session::with_new() } + Self { session: Session::with_new(), roots: None } + } + + /// Confine every file the session's commands read and write to `roots` + /// (`deckcraft-cli mcp --automation-read-root … --automation-write-root …`). + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self } fn exec(&mut self, id: &str, params: &Value) -> Result { @@ -84,11 +96,13 @@ impl Headless { let mut last = Value::Null; for e in events { let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + let kind = pointer_kind(kind) + .ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick|tripleclick|contextclick)"))?; let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; let mods = e.get("mods").map(mods_from).unwrap_or(base); - last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?; + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0, pressure }).map_err(|e| e.to_string())?; } self.session.ui_requests.clear(); Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) @@ -106,9 +120,9 @@ impl Headless { i, &deckcraft_engine::render::RenderOpts { scale, edit: p.get("edit").and_then(Value::as_bool).unwrap_or(false), ..Default::default() }, ); - let png = img.to_png(); + let png = img.to_png_at(deckcraft_engine::render::dpi_for_scale(scale)); if let Some(path) = s(p, "path") { - std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?; + deckcraft_engine::io::write_file(path, &png)?; return Ok(json!({"path": path, "width": img.width, "height": img.height})); } Ok(json!({"png": deckcraft_engine::cmd::base64_encode(&png), "width": img.width, "height": img.height, "slide": i})) @@ -117,6 +131,19 @@ impl Headless { impl Backend for Headless { fn call(&mut self, method: &str, params: Value) -> Result { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "headless (in-process engine; no window)".into() + } +} + +impl Headless { + fn answer(&mut self, method: &str, params: Value) -> Result { let p = ¶ms; match method { "engine.execute" | "command" | "ui.menu.invoke" => { @@ -150,10 +177,4 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } - fn has_ui(&self) -> bool { - false - } - fn describe(&self) -> String { - "headless (in-process engine; no window)".into() - } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 576e0b4a..710067cd 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone) current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \ list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \ {layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \ -inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \ -run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \ -on `ids`. Save with save_presentation (.deckcraft or .pptx)."; +inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \ +current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \ +the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \ +those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx)."; /// Resource URIs. pub const DOC_URI: &str = "deckcraft://document"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b1521521..f34ee867 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -79,6 +79,69 @@ fn agent_builds_a_deck() { assert_eq!(n2, n1 + 1); } +#[test] +fn set_text_unknown_id_is_error() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let title_id = tool(&mut s, "inspect_slide", json!({}))["shapes"][0]["id"].as_u64().unwrap(); + tool(&mut s, "select", json!({"ids": [title_id]})); + // -1 and 2^32 are not shape ids; they must not fall back to the selection. + for bad in [json!(999999), json!(-1), json!(4294967296u64)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "set_text", "arguments": {"id": bad, "text": "x"}})); + assert_eq!(r["result"]["isError"], true, "{bad}: {r}"); + } + // A real id, and a just-created shape, still work; omitting id still uses the selection. + tool(&mut s, "set_text", json!({"id": title_id, "text": "Real"})); + let sid = tool(&mut s, "add_shape", json!({"preset": "rect", "x": 10, "y": 10, "w": 50, "h": 50}))["id"].as_u64().unwrap(); + tool(&mut s, "set_text", json!({"id": sid, "text": "Fresh"})); + tool(&mut s, "select", json!({"ids": [title_id]})); + tool(&mut s, "set_text", json!({"text": "Selected"})); + let sl = tool(&mut s, "inspect_slide", json!({})); + let text_of = |id: u64| sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap()["text"].clone(); + assert_eq!(text_of(sid), "Fresh"); + assert_eq!(text_of(title_id), "Selected"); +} + +#[test] +fn invalid_id_never_falls_back_to_selection() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let title_id = tool(&mut s, "inspect_slide", json!({}))["shapes"][0]["id"].as_u64().unwrap(); + tool(&mut s, "select", json!({"ids": [title_id]})); + for cmd in ["text.edit", "text.get", "shape.inspect", "table.selectCells", "arrange.reorder"] { + for bad in [json!(-1), json!(1.5), json!("7"), json!(4294967296u64)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "run_command", "arguments": {"command": cmd, "params": {"id": bad, "index": 0}}})); + assert_eq!(r["result"]["isError"], true, "{cmd} {bad}: {r}"); + } + } + // Absent id still means the selection, and a real id still works. + tool(&mut s, "run_command", json!({"command": "text.get", "params": {}})); + tool(&mut s, "run_command", json!({"command": "text.get", "params": {"id": title_id}})); + tool(&mut s, "run_command", json!({"command": "shape.inspect", "params": {"id": title_id}})); +} + +#[test] +fn unknown_tool_arguments_are_refused() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({})); + let charts = |v: &Value| v["shapes"].as_array().unwrap().iter().filter(|sh| sh["kind"] == "chart").count(); + let before = charts(&tool(&mut s, "inspect_slide", json!({}))); + let r = call(&mut s, 2, "tools/call", json!({"name": "insert_chart", "arguments": {"type": "column", "data": [["x", "A"], ["q1", 1]]}})); + assert_eq!(r["result"]["isError"], true); + let msg = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(msg.contains("unknown argument \"data\"") && msg.contains("categories") && msg.contains("series"), "{msg}"); + assert_eq!(charts(&tool(&mut s, "inspect_slide", json!({}))), before, "a refused call inserts nothing"); + let r = call(&mut s, 3, "tools/call", json!({"name": "list_commands", "arguments": {"query": "theme", "limit": 1}})); + assert_eq!(r["result"]["isError"], true); + let msg = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(msg.contains("unknown arguments \"limit\", \"query\"") && msg.contains("filter"), "{msg}"); + // The command's own parameters stay inside `params`. + tool(&mut s, "run_command", json!({"command": "slide.new", "params": {"layout": "blank"}})); + let tools = call(&mut s, 4, "tools/list", json!({})); + let chart = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "insert_chart").unwrap(); + assert_eq!(chart["inputSchema"]["additionalProperties"], false); +} + #[test] fn errors_are_reported_not_panics() { let mut s = Server::new(Box::new(Headless::new())); @@ -90,6 +153,26 @@ fn errors_are_reported_not_panics() { assert_eq!(r["result"]["isError"], true); } +fn current_slide_title(s: &mut Server) -> Value { + tool(s, "inspect_slide", json!({}))["title"].clone() +} + +#[test] +fn go_to_slide_out_of_range_is_error() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + tool(&mut s, "add_slide", json!({"layout": "titleAndContent", "title": "Second", "body": "x"})); + // Boundaries stay valid: first and last slide. + tool(&mut s, "go_to_slide", json!({"index": 0})); + tool(&mut s, "go_to_slide", json!({"index": 1})); + assert_eq!(current_slide_title(&mut s), "Second"); + for bad in [json!(2), json!(99)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "go_to_slide", "arguments": {"index": bad}})); + assert_eq!(r["result"]["isError"], true, "{bad}: {r}"); + assert_eq!(current_slide_title(&mut s), "Second", "failed go_to_slide must not move"); + } +} + #[test] fn export_tool_lists_pdf_and_its_options() { let mut s = Server::new(Box::new(Headless::new())); @@ -103,7 +186,7 @@ fn export_tool_lists_pdf_and_its_options() { } assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"])); assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9])); - for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] { + for p in ["dpi", "slides", "includeHidden", "textLayer", "frame", "paper", "orientation"] { assert!(props.get(p).is_some(), "{p}: {export}"); } // What the schema describes is accepted. @@ -112,8 +195,50 @@ fn export_tool_lists_pdf_and_its_options() { tool(&mut s, "new_presentation", json!({})); for layout in ["slides", "notes", "handouts"] { let path = dir.join(format!("{layout}.pdf")); - tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3})); + tool( + &mut s, + "export", + json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3, "paper": "a4", "orientation": "landscape"}), + ); assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}"); } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn export_rejects_unknown_format() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let path = std::env::temp_dir().join(format!("deckcraft-mcp-unknown-format-{}.docx", std::process::id())); + let _ = std::fs::remove_file(&path); + let r = call(&mut s, 1, "tools/call", json!({"name": "export", "arguments": {"path": path.to_str().unwrap(), "format": "docx"}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("docx"), "{r}"); + assert!(!path.exists(), "nothing may be written for an unknown format"); +} + +#[test] +fn export_tool_lists_gif_and_its_options() { + let mut s = Server::new(Box::new(Headless::new())); + let tools = call(&mut s, 1, "tools/list", json!({})); + let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone(); + assert!(export["description"].as_str().unwrap().contains("gif"), "{export}"); + let props = &export["inputSchema"]["properties"]; + assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "gif"), "{export}"); + assert_eq!(props["width"]["oneOf"][1]["enum"], json!(["small", "medium", "large", "extraLarge"])); + for p in ["secondsPerSlide", "loop", "slides", "includeHidden"] { + assert!(props.get(p).is_some(), "{p}: {export}"); + } + // What the schema describes is accepted, and bad values are tool errors, not crashes. + let dir = std::env::temp_dir().join(format!("deckcraft-mcp-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + tool(&mut s, "new_presentation", json!({})); + for width in [json!("small"), json!(200)] { + let path = dir.join("deck.gif"); + tool(&mut s, "export", json!({"path": path.to_string_lossy(), "width": width, "secondsPerSlide": 2, "loop": false, "slides": [0]})); + assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"), "{width}"); + } + let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"path": dir.join("bad.gif").to_string_lossy(), "width": 0}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1a457dda..b964ea10 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -47,10 +47,10 @@ fn boolean(desc: &str) -> Value { json!({"type": "boolean", "description": desc}) } fn mods_schema() -> Value { - json!({"type": "object", "description": "Modifier keys (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}}) + json!({"type": "object", "additionalProperties": false, "description": "Modifier keys (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}}) } fn obj(props: Value, required: &[&str]) -> Value { - let mut o = json!({"type": "object", "properties": props}); + let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); if !required.is_empty() { o["required"] = json!(required); } @@ -115,21 +115,32 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).", + "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).", obj( json!({ "path": string("Output path"), - "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"}, + "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"}, "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)"), "layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"}, "perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"}, "dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"), - "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"}, - "includeHidden": boolean("PDF: include hidden slides (default false)"), + "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"}, + "includeHidden": boolean("PDF and GIF: include hidden slides (default false)"), "textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"), - "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)") + "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"), + "paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"}, + "orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"}, + "width": { + "oneOf": [ + {"type": "integer", "minimum": 16, "maximum": 3840}, + {"type": "string", "enum": ["small", "medium", "large", "extraLarge"]} + ], + "description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape" + }, + "secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"}, + "loop": boolean("GIF: loop forever (true, default) or play once (false)") }), &["path"], ), @@ -240,9 +251,9 @@ pub fn tool_definitions() -> Vec { tool( "pointer", "Pointer gesture", - "Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret).", + "Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick/contextclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret; contextclick selects like a right click).", obj( - json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}), + json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick|tripleclick|contextclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}), &["events"], ), false, @@ -294,8 +305,37 @@ fn wrap(r: Result) -> ToolResult { } } +/// Keys other than the tool's schema properties are refused, so a misspelled argument does not +/// succeed with the tool's defaults. Keys inside a free object such as `run_command`'s `params` +/// belong to the command and are left for the command to read. +fn unknown_argument_error(name: &str, args: &Value) -> Option { + let defs = tool_definitions(); + let def = defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name))?; + let mut allowed: Vec<&str> = def + .get("inputSchema") + .and_then(|s| s.get("properties")) + .and_then(Value::as_object) + .map(|p| p.keys().map(String::as_str).collect()) + .unwrap_or_default(); + allowed.sort_unstable(); + let obj = args.as_object()?; + let mut unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !allowed.contains(k)).collect(); + unknown.sort_unstable(); + let first = unknown.first()?; + let expected = if allowed.is_empty() { "none".to_string() } else { allowed.join(", ") }; + let (what, list) = if unknown.len() == 1 { + ("unknown argument", format!("{first:?}")) + } else { + ("unknown arguments", unknown.iter().map(|k| format!("{k:?}")).collect::>().join(", ")) + }; + Some(format!("{name}: {what} {list} (expected: {expected})")) +} + pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let a = if a.is_object() { a.clone() } else { json!({}) }; + if let Some(msg) = unknown_argument_error(name, &a) { + return ToolResult::error(msg); + } match name { "list_commands" => wrap(b.call("engine.commands", json!({})).map(|v| { let f = a.get("filter").and_then(Value::as_str).unwrap_or("").to_lowercase(); diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 00000000..ed67fb3e --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,225 @@ +//! `deckcraft-cli mcp --automation-read-root --automation-write-root `: through the MCP +//! protocol, files open and load only from inside the read root and are saved and exported only +//! inside the write root. Paths that leave a root (`..`, another folder, a sibling with the same +//! prefix, a link) are refused, and nothing is written outside. +// 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::path::{Path, PathBuf}; + +use deckcraft_engine::file_access::AutomationRoots; +use deckcraft_mcp::{Headless, Server}; +use serde_json::{Value, json}; + +/// A 1x1 PNG, so `insert_picture` has something real to read. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A fresh folder holding `in` (the read root, with `a.txt` and `pic.png`), `out` (the write root), +/// `outside` (with `secret.txt` and `pic.png`) and `in2`, and a server confined to `in` and `out` +/// (either may be left out). The server starts with a new presentation. +fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.txt"), "Inside text").unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap(); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap(); + let (in_dir, out_dir) = (base.join("in"), base.join("out")); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); + ok(&mut server, "new_presentation", json!({})); + (server, base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// `tools/call` -> (isError, the first text block). +fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string(); + let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap(); + let result = &reply["result"]; + let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string(); + (result["isError"] == true, text) +} + +fn ok(server: &mut Server, name: &str, args: Value) -> String { + let (error, text) = call(server, name, args.clone()); + assert!(!error, "{name} {args}: {text}"); + text +} + +fn refused(server: &mut Server, name: &str, args: Value, why: &str) { + let (error, text) = call(server, name, args.clone()); + assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); +} + +fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) { + call(server, "run_command", json!({"command": command, "params": params})) +} + +/// Everything the open presentation says, as one string. +fn deck_text(server: &mut Server) -> String { + format!("{} {}", ok(server, "inspect_document", json!({})), ok(server, "inspect_slide", json!({}))) +} + +#[test] +fn presentations_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "open_presentation", json!({"path": s(&base.join("in/a.txt"))})); + assert!(deck_text(&mut server).contains("Inside text")); + + refused(&mut server, "open_presentation", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root"); + refused( + &mut server, + "open_presentation", + json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}), + "outside the read root", + ); + refused(&mut server, "open_presentation", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root"); + // The write root isn't readable: the two kinds of access are separate. + std::fs::write(base.join("out/d.txt"), "d").unwrap(); + refused(&mut server, "open_presentation", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root"); + // The same through the engine command and the control-channel method. + let (error, text) = run(&mut server, "file.open", json!({"path": s(&base.join("outside/secret.txt"))})); + assert!(error && text.contains("outside the read root"), "{text}"); + refused(&mut server, "call_app", json!({"method": "app.open", "params": {"path": s(&base.join("outside/secret.txt"))}}), "outside the read root"); + // Nothing from the refused files reached the deck. + let text = deck_text(&mut server); + assert!(!text.contains("TOP SECRET") && !text.contains("Sibling"), "{text}"); + assert!(text.contains("Inside text"), "the refused opens left the deck it had: {text}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn pictures_media_and_other_path_reads_are_confined() { + let (mut server, base) = confined("media", true, true); + ok(&mut server, "insert_picture", json!({"path": s(&base.join("in/pic.png"))})); + let before = ok(&mut server, "inspect_slide", json!({})); + refused(&mut server, "insert_picture", json!({"path": s(&base.join("outside/pic.png"))}), "outside the read root"); + for command in ["insert.audio", "insert.video", "insert.picture", "picture.change"] { + let (error, text) = run(&mut server, command, json!({"path": s(&base.join("outside/pic.png"))})); + assert!(error && text.contains("outside the read root"), "{command}: {text}"); + } + assert_eq!(ok(&mut server, "inspect_slide", json!({})), before, "refused reads change nothing"); + + // Media inside the root gets as far as being read: a refusal there would name the root. + for command in ["insert.audio", "insert.video"] { + let (_, text) = run(&mut server, command, json!({"path": s(&base.join("in/pic.png"))})); + assert!(!text.contains("automation"), "{command}: {text}"); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn every_writing_command_is_refused_outside_the_write_root() { + let (mut server, base) = confined("write", true, true); + let outside = |name: &str| s(&base.join("outside").join(name)); + let inside = |name: &str| s(&base.join("out").join(name)); + ok(&mut server, "add_slide", json!({"title": "Second"})); + + refused(&mut server, "save_presentation", json!({"path": outside("x.deckcraft")}), "outside the write root"); + refused(&mut server, "export", json!({"path": outside("x.pptx")}), "outside the write root"); + refused(&mut server, "export", json!({"path": outside("x.pdf")}), "outside the write root"); + refused(&mut server, "export", json!({"path": outside("x.png")}), "outside the write root"); + refused(&mut server, "export", json!({"path": outside("x.txt")}), "outside the write root"); + refused(&mut server, "export", json!({"path": outside("all.png"), "all": true}), "outside the write root"); + refused(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": outside("r.png")}}), "outside the write root"); + refused(&mut server, "call_app", json!({"method": "app.save", "params": {"path": outside("y.deckcraft")}}), "outside the write root"); + refused(&mut server, "call_app", json!({"method": "app.export", "params": {"path": outside("y.pdf")}}), "outside the write root"); + for command in ["file.save", "file.saveAs", "file.export"] { + let (error, text) = run(&mut server, command, json!({"path": outside("z.deckcraft")})); + assert!(error && text.contains("outside the write root"), "{command}: {text}"); + } + // A sibling folder with the same prefix, and `..` out of the root. + refused(&mut server, "save_presentation", json!({"path": s(&base.join("out2/x.deckcraft"))}), "outside the write root"); + let sep = std::path::MAIN_SEPARATOR; + refused( + &mut server, + "save_presentation", + json!({"path": format!("{}{sep}..{sep}outside{sep}x.deckcraft", s(&base.join("out")))}), + "outside the write root", + ); + // The read root isn't writable. + refused(&mut server, "save_presentation", json!({"path": s(&base.join("in/x.deckcraft"))}), "outside the write root"); + + let leftovers: Vec<_> = std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(leftovers.len(), 2, "nothing was written beside secret.txt and pic.png: {leftovers:?}"); + assert!(!base.join("out2").exists() && !base.join("in/x.deckcraft").exists()); + + // Inside the write root all of it works. + ok(&mut server, "save_presentation", json!({"path": inside("x.deckcraft")})); + ok(&mut server, "export", json!({"path": inside("x.pptx")})); + ok(&mut server, "export", json!({"path": inside("x.pdf")})); + ok(&mut server, "export", json!({"path": inside("all.png"), "all": true})); + ok(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": inside("r.png")}})); + for f in ["x.deckcraft", "x.pptx", "x.pdf", "all1.png", "all2.png", "r.png"] { + assert!(base.join("out").join(f).exists(), "{f}"); + } + let temporaries: Vec<_> = std::fs::read_dir(base.join("out")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".saving")) + .collect(); + assert!(temporaries.is_empty(), "the temporary file is renamed away"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn one_root_grants_only_its_own_access() { + let (mut read_only, base) = confined("read-only", true, false); + ok(&mut read_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))})); + refused(&mut read_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}), "write authority is absent"); + refused(&mut read_only, "export", json!({"path": s(&base.join("out/x.pdf"))}), "write authority is absent"); + refused(&mut read_only, "call_app", json!({"method": "ui.render", "params": {"path": s(&base.join("out/r.png"))}}), "write authority is absent"); + // Calls that return bytes instead of writing a path keep working. + let (error, _) = call(&mut read_only, "render_slide", json!({"scale": 0.25})); + assert!(!error); + assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); + + let (mut write_only, base) = confined("write-only", false, true); + refused(&mut write_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent"); + refused(&mut write_only, "insert_picture", json!({"path": s(&base.join("in/pic.png"))}), "read authority is absent"); + ok(&mut write_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))})); + assert!(base.join("out/x.deckcraft").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + std::os::unix::fs::symlink(base.join("outside/secret.txt"), base.join("in/leak.txt")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/gone.deckcraft"), base.join("out/dangling.deckcraft")).unwrap(); + refused(&mut server, "open_presentation", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root"); + refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/away/x.deckcraft"))}), "outside the write root"); + refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/dangling.deckcraft"))}), "a link that leads nowhere"); + assert!(!base.join("outside/x.deckcraft").exists() && !base.join("outside/gone.deckcraft").exists()); + assert!(!deck_text(&mut server).contains("TOP SECRET")); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn without_roots_nothing_is_confined() { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("a.txt"), "Anywhere").unwrap(); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap()))); + ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))})); + ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))})); + assert!(base.join("b.deckcraft").exists()); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/media/src/audio.rs b/crates/media/src/audio.rs index 80ad66a4..80fe0b43 100644 --- a/crates/media/src/audio.rs +++ b/crates/media/src/audio.rs @@ -71,13 +71,14 @@ pub fn decode(bytes: &Bytes) -> Result { Container::Asf => Err(MediaError::Unsupported("Windows Media Audio (WMA)".into())), Container::Ogg => match decode_ogg_opus(bytes) { Some(r) => r, - None => decode_symphonia(bytes, "ogg"), + None => contained(|| decode_symphonia(bytes, "ogg")), }, Container::Matroska => match decode_mkv_opus(bytes) { Some(r) => r, - None => decode_symphonia(bytes, "mkv"), + None => contained(|| decode_symphonia(bytes, "mkv")), }, - c => decode_symphonia(bytes, c.name()), + Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())), + c => contained(|| decode_symphonia(bytes, c.name())), } } @@ -92,7 +93,7 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option> { let mut dec = deckcraft_opus::Decoder::new(head).map_err(|e| MediaError::Corrupt(e.to_string()))?; dec.set_trim_pre_skip(true); let channels = dec.channels() as u16; - let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32); + let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?; let mut out = Vec::new(); for i in 0..stream.packets.len() { let Ok(p) = file.read_packet(src, s, i) else { continue }; @@ -142,6 +143,15 @@ fn sym_err(e: SymError) -> MediaError { } } +/// Runs symphonia, whose demuxers can panic on damaged files (Matroska's EBML reader asserts +/// its position, for one): the panic becomes a corrupt-file error instead of ending the app. +fn contained(f: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MediaError::Corrupt("the audio could not be read".into()))) +} + +/// Demux errors in a row after which a file is taken to have no more audio. +const MAX_DEMUX_ERRORS: u32 = 64; + fn decode_symphonia(bytes: &Bytes, ext: &str) -> Result { let mss = MediaSourceStream::new(Box::new(Cursor::new(Shared(bytes.clone()))), MediaSourceStreamOptions::default()); let mut hint = Hint::new(); @@ -172,12 +182,21 @@ fn decode_symphonia(bytes: &Bytes, ext: &str) -> Result { let mut rate = 0; let mut channels = 0u16; let mut buf: Option> = None; + // A damaged file can make the demuxer report the same error forever without moving on. + let mut demux_errors = 0u32; loop { let packet = match format.next_packet() { - Ok(p) => p, + Ok(p) => { + demux_errors = 0; + p + } Err(SymError::IoError(_)) | Err(SymError::ResetRequired) => break, Err(SymError::DecodeError(e)) => { log::debug!("audio demux: {e}"); + demux_errors += 1; + if demux_errors > MAX_DEMUX_ERRORS { + break; + } continue; } Err(e) => { diff --git a/crates/media/src/feed.rs b/crates/media/src/feed.rs index 721be858..f4b5fce4 100644 --- a/crates/media/src/feed.rs +++ b/crates/media/src/feed.rs @@ -66,8 +66,8 @@ impl VideoFeed { #[cfg(not(target_arch = "wasm32"))] { let _ = self.tx.send(Cmd::Seek(t, self.generation)); - // Unblock the worker if it's waiting to hand over a stale frame. - while self.rx.try_recv().is_ok() {} + // pull() discards stale generations and unblocks the worker. Draining here could + // also discard the new generation's frames or EOF if the worker handles seek fast. } #[cfg(target_arch = "wasm32")] self.dec.seek(t); @@ -178,3 +178,57 @@ fn worker(mut dec: VideoDecoder, cmds: std::sync::mpsc::Receiver, frames: s } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn seek_keeps_new_generation_frames_and_end_of_stream() { + let (tx, _commands) = std::sync::mpsc::channel(); + let (frames, rx) = std::sync::mpsc::sync_channel(AHEAD); + let frame = |time| Frame { time, width: 1, height: 1, rgba: vec![0, 0, 0, 255] }; + frames.send((0, Some(frame(0.3)))).expect("stale frame"); + frames.send((0, None)).expect("stale end of stream"); + // Model a fast worker's seek response already waiting when seek drains the queue. + frames.send((1, Some(frame(0.0)))).expect("new frame"); + frames.send((1, None)).expect("new end of stream"); + let mut feed = VideoFeed { size: (1, 1), current: None, next: None, generation: 0, ended: false, tx, rx }; + + feed.seek(0.05); + let shown = feed.frame_at(0.05).expect("frame from the new generation"); + assert_eq!(shown.time, 0.0); + assert!(feed.ended()); + + frames.send((2, Some(frame(0.3)))).expect("superseded seek frame"); + frames.send((2, None)).expect("superseded seek end of stream"); + frames.send((3, Some(frame(0.0)))).expect("latest seek frame"); + frames.send((3, None)).expect("latest seek end of stream"); + feed.seek(0.35); + feed.seek(0.05); + assert_eq!(feed.frame_at(0.05).expect("latest seek wins").time, 0.0); + assert!(feed.ended()); + } + + #[test] + fn seek_unblocks_worker_when_stale_queue_is_full() { + let (tx, commands) = std::sync::mpsc::channel(); + let (frames, rx) = std::sync::mpsc::sync_channel(AHEAD); + let frame = || Frame { time: 0.0, width: 1, height: 1, rgba: vec![0, 0, 0, 255] }; + for _ in 0..AHEAD { + frames.send((0, Some(frame()))).expect("fill stale queue"); + } + let worker = std::thread::spawn(move || { + let Cmd::Seek(_, generation) = commands.recv().expect("seek command"); + frames.send((generation, Some(frame()))).expect("seek frame"); + frames.send((generation, None)).expect("seek end of stream"); + }); + let mut feed = VideoFeed { size: (1, 1), current: None, next: None, generation: 0, ended: false, tx, rx }; + + feed.seek(0.05); + let shown = feed.frame_at_blocking(0.05, std::time::Duration::from_secs(5)).expect("worker unblocked"); + assert_eq!(shown.time, 0.0); + assert!(feed.ended()); + worker.join().expect("worker completed"); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d31b5d61..95150868 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -19,6 +19,7 @@ pub mod audio; pub mod clip; pub mod feed; +mod mp4guard; pub mod player; pub mod probe; pub mod video; diff --git a/crates/media/src/mp4guard.rs b/crates/media/src/mp4guard.rs new file mode 100644 index 00000000..ff8e8613 --- /dev/null +++ b/crates/media/src/mp4guard.rs @@ -0,0 +1,76 @@ +//! Checks an MP4's box tree before symphonia reads it. Symphonia's MP4 reader reserves memory +//! from the entry counts in the sample tables (and reads each sample whole at its `stsz` size) +//! without checking them against the file, so a damaged count asks for gigabytes and the process +//! aborts — an allocation failure cannot be caught. A file passes when every box fits inside its +//! parent, every table's entries fit inside its box and every sample fits inside the file. + +/// Boxes whose payload is a sequence of boxes. +const CONTAINERS: [&[u8; 4]; 11] = [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"edts", b"dinf", b"udta", b"mvex", b"moof", b"traf"]; + +/// Whether symphonia can be given `b` (an MP4) without a damaged size reaching an allocation. +pub(crate) fn tables_fit(b: &[u8]) -> bool { + boxes_fit(b, b.len() as u64, 0) +} + +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64) +} + +fn boxes_fit(b: &[u8], file_len: u64, depth: u32) -> bool { + if depth > 16 { + return false; + } + let mut at = 0usize; + while b.len() - at >= 8 { + let Some(size32) = be32(b, at) else { return false }; + let kind = &b[at + 4..at + 8]; + let (header, size) = match size32 { + 0 => (8, (b.len() - at) as u64), + 1 => match b.get(at + 8..at + 16) { + Some(s) => (16, u64::from_be_bytes(s.try_into().unwrap_or_default())), + None => return false, + }, + n => (8, n), + }; + if size < header as u64 || size > (b.len() - at) as u64 { + // A box that runs past the end of the file is fine at the top level (the file was cut + // short; symphonia stops there), but never inside a parent. + return depth == 0 && !matches!(kind, b"moov" | b"moof"); + } + let body = &b[at + header..at + size as usize]; + let ok = if CONTAINERS.iter().any(|c| c.as_slice() == kind) { + boxes_fit(body, file_len, depth + 1) + } else if kind == b"meta" { + // ISO `meta` is a full box (4 bytes of version and flags first); QuickTime's is not. + let skip = if be32(body, 0) == Some(0) { 4 } else { 0 }; + boxes_fit(&body[skip.min(body.len())..], file_len, depth + 1) + } else { + table_fits(kind, body, file_len) + }; + if !ok { + return false; + } + at += size as usize; + } + true +} + +/// A sample table (a full box: version and flags, then a count and that many entries). +fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool { + let entry = match kind { + b"stts" | b"ctts" | b"co64" => 8, + b"stsc" => 12, + b"stco" | b"stss" => 4, + b"stsz" => { + let (Some(size), Some(count)) = (be32(body, 4), be32(body, 8)) else { return false }; + if size != 0 { + return size <= file_len; + } + let Some(entries) = body.get(12..) else { return false }; + return count <= entries.len() as u64 / 4 + && entries.as_chunks::<4>().0.iter().take(count as usize).all(|s| u32::from_be_bytes(*s) as u64 <= file_len); + } + _ => return true, + }; + be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry) +} diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index 9ac4a804..dbb9d2c0 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -6,11 +6,14 @@ //! heard. Without an output device (web, CI, no sound card) [`Player::tick`] advances the clocks on //! wall time instead, so playback, looping and video still work silently. //! -//! Audio is decoded once per media item (on a worker thread where there are threads) and cached; -//! a voice whose audio is still decoding holds at its start (`Loading`), one whose audio can't be -//! decoded runs silently. +//! Audio is decoded once per media item (on a worker thread where there are threads) and cached. +//! The cache keeps those source bytes: its key is the allocation's address, which would otherwise +//! be free to reuse for different media. A voice whose audio is still decoding holds at its start +//! (`Loading`); one whose audio can't be decoded runs silently. Playing a voice again with a +//! different source replaces its audio instead of keeping the previous clip. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::{Bytes, ClipParams, Pcm}; @@ -20,6 +23,12 @@ use crate::{Bytes, ClipParams, Pcm}; pub trait AudioOut { fn start(&mut self, fill: Box) -> Result<(), String>; fn stop(&mut self); + /// Why the started stream has died, if it has (the device went away), reported once. Polled + /// by [`Player::tick`] on the host thread; must not block. Outputs that cannot tell return + /// `None`: the player also notices a stream whose callbacks stopped. + fn failure(&mut self) -> Option { + None + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -66,8 +75,17 @@ pub struct VoiceStatus { type Slot = Arc, String>>>; +/// One decoded item. `source` keeps the allocation `media_key` names alive for as long as the +/// entry does, so a later buffer cannot be given that address and read this PCM. +struct CachedAudio { + slot: Slot, + source: Bytes, +} + struct Voice { key: u64, + /// Identity of the bytes this voice is playing. A different key replaces `audio`. + media_key: u64, audio: Option, spec: VoiceSpec, t: f64, @@ -186,19 +204,49 @@ impl Mixer { /// Default mix rate when no device says otherwise. const DEFAULT_RATE: u32 = 48_000; +/// Seconds without a single device callback, while a voice plays, after which the stream counts +/// as stalled and the wall clock takes over. +const STALL_SECS: f64 = 1.0; + +/// Seconds a just-started stream may take to ask for its first samples (Bluetooth and other slow +/// devices) before it counts as stalled. +const FIRST_CALLBACK_SECS: f64 = 5.0; + +/// Output failures after which the player stops retrying the device and plays silently. +const MAX_OUTPUT_FAILURES: u32 = 3; + pub struct Player { out: Option>, streaming: bool, mixer: Arc>, last_tick: Option, idle_since: Option, - cache: HashMap, + cache: HashMap, + /// Device callbacks so far (the audio thread counts, [`Player::tick`] watches). + pulls: Arc, + /// The pull count last seen by `tick` and when it last changed. + pulls_seen: Option<(u64, f64)>, + /// Times the output failed or stalled after starting. + failures: u32, + /// `pulls` when the current stream started: callbacks beyond it mean the device is running. + pulls_at_start: u64, } impl Player { /// A player over `out` (`None`: silent, clocked by [`Player::tick`]). pub fn new(out: Option>) -> Player { - Player { out, streaming: false, mixer: Arc::new(Mutex::new(Mixer::default())), last_tick: None, idle_since: None, cache: HashMap::new() } + Player { + out, + streaming: false, + mixer: Arc::new(Mutex::new(Mixer::default())), + last_tick: None, + idle_since: None, + cache: HashMap::new(), + pulls: Arc::new(AtomicU64::new(0)), + pulls_seen: None, + failures: 0, + pulls_at_start: 0, + } } pub fn has_output(&self) -> bool { @@ -213,49 +261,61 @@ impl Player { /// The decoded audio of `bytes`, cached by `media_key` (decoding starts now). fn slot(&mut self, media_key: u64, bytes: &Bytes) -> Slot { if self.cache.len() > 16 { - // Forget audio no voice uses. - let used: Vec = self.cache.iter().filter(|(_, s)| Arc::strong_count(s) > 1).map(|(k, _)| *k).collect(); + // Forget audio no voice uses. Dropping the entry drops its source bytes too. + let used: Vec = self.cache.iter().filter(|(_, c)| Arc::strong_count(&c.slot) > 1).map(|(k, _)| *k).collect(); self.cache.retain(|k, _| used.contains(k)); } - self.cache - .entry(media_key) - .or_insert_with(|| { - let slot: Slot = Arc::new(OnceLock::new()); - let (s, b) = (slot.clone(), bytes.clone()); - let job = move || { - let _ = s.set(crate::audio::decode(&b).map(Arc::new).map_err(|e| e.to_string())); - }; - if crate::THREADS { - if std::thread::Builder::new().name("deckcraft-audio-decode".into()).spawn(job).is_err() { - let _ = slot.set(Err("couldn't start the decoder".into())); - } - } else { - job(); - } - slot - }) - .clone() + if let Some(hit) = self.cache.get(&media_key) + && Arc::as_ptr(&hit.source) as u64 == media_key + { + return hit.slot.clone(); + } + // A key whose bytes are gone is not this clip. Decode again. + self.cache.remove(&media_key); + let slot: Slot = Arc::new(OnceLock::new()); + let (s, b) = (slot.clone(), bytes.clone()); + let job = move || { + let _ = s.set(crate::audio::decode(&b).map(Arc::new).map_err(|e| e.to_string())); + }; + if crate::THREADS { + if std::thread::Builder::new().name("deckcraft-audio-decode".into()).spawn(job).is_err() { + let _ = slot.set(Err("couldn't start the decoder".into())); + } + } else { + job(); + } + let cached = CachedAudio { slot: slot.clone(), source: bytes.clone() }; + self.cache.insert(media_key, cached); + slot } /// Play voice `key` (a shape) from `from` seconds (default: the trimmed start). Resumes a - /// paused voice when `from` is `None`. + /// paused voice when `from` is `None` and `media_key` is unchanged. A different `media_key` + /// replaces the decoded audio and starts the new clip (at `from`, or at its start). pub fn play(&mut self, key: u64, media_key: u64, bytes: &Bytes, spec: VoiceSpec, from: Option) { let audio = spec.audio.then(|| self.slot(media_key, bytes)); self.with(|m| { if let Some(v) = m.voices.iter_mut().find(|v| v.key == key) { + let same = v.media_key == media_key; + v.media_key = media_key; v.spec = spec; - if v.audio.is_none() { + if same { + if v.audio.is_none() { + v.audio = audio; + } + if let Some(t) = from { + v.t = t; + } else if v.state == PlayState::Ended { + v.t = spec.clip.range(v.duration()).0; + } + } else { v.audio = audio; - } - if let Some(t) = from { - v.t = t; - } else if v.state == PlayState::Ended { - v.t = spec.clip.range(v.duration()).0; + v.t = from.unwrap_or_else(|| spec.clip.range(spec.duration).0); } v.state = PlayState::Playing; } else { - let t = from.unwrap_or(spec.clip.range(spec.duration).0); - m.voices.push(Voice { key, audio, spec, t, state: PlayState::Playing }); + let t = from.unwrap_or_else(|| spec.clip.range(spec.duration).0); + m.voices.push(Voice { key, media_key, audio, spec, t, state: PlayState::Playing }); } }); self.ensure_stream(); @@ -267,15 +327,21 @@ impl Player { } let Some(out) = self.out.as_mut() else { return }; let m = self.mixer.clone(); + let pulls = self.pulls.clone(); let fill = Box::new(move |buf: &mut [f32], channels: usize, rate: u32| match m.lock() { Ok(mut g) => { + pulls.fetch_add(1, Ordering::Relaxed); let frames = buf.len() / channels.max(1); g.mix(Some(buf), frames, channels, rate) } Err(_) => buf.fill(0.0), }); match out.start(fill) { - Ok(()) => self.streaming = true, + Ok(()) => { + self.streaming = true; + self.pulls_seen = None; + self.pulls_at_start = self.pulls.load(Ordering::Relaxed); + } Err(e) => { log::warn!("audio output unavailable ({e}); media plays silently"); self.out = None; @@ -338,6 +404,7 @@ impl Player { pub fn tick(&mut self, now: f64) { let dt = self.last_tick.map(|l| (now - l).clamp(0.0, 0.25)).unwrap_or(0.0); self.last_tick = Some(now); + self.check_output(now); if !self.streaming && dt > 0.0 { let frames = (dt * DEFAULT_RATE as f64).round() as usize; self.with(|m| m.mix(None, frames, 2, DEFAULT_RATE)); @@ -355,6 +422,56 @@ impl Player { } } + /// Drop a stream that died or stalled, so the voices fall back to the wall clock instead of + /// freezing. The next `play` tries the device again, up to [`MAX_OUTPUT_FAILURES`] times. + fn check_output(&mut self, now: f64) { + if !self.streaming { + return; + } + // Only a playing voice needs callbacks; an idle stream may legitimately sleep. + let playing = self.any_playing(); + let Some(out) = self.out.as_mut() else { return }; + let pulls = self.pulls.load(Ordering::Relaxed); + let running = pulls > self.pulls_at_start; + // A stream that delivers again has recovered: later failures start counting afresh. + if running { + self.failures = 0; + } + let limit = if running { STALL_SECS } else { FIRST_CALLBACK_SECS }; + let reason = if let Some(e) = out.failure() { + Some(e) + } else { + match self.pulls_seen { + Some((seen, since)) if seen == pulls => { + if !playing { + self.pulls_seen = Some((pulls, now)); + None + } else if now - since > limit { + Some("the audio device stopped asking for samples".to_string()) + } else { + None + } + } + _ => { + self.pulls_seen = Some((pulls, now)); + None + } + } + }; + let Some(reason) = reason else { return }; + out.stop(); + self.streaming = false; + self.pulls_seen = None; + self.idle_since = None; + self.failures = self.failures.saturating_add(1); + if self.failures >= MAX_OUTPUT_FAILURES { + log::warn!("audio output failed ({reason}); giving up on the device, media plays silently"); + self.out = None; + } else { + log::warn!("audio output failed ({reason}); media plays silently until it restarts"); + } + } + /// Block until voice `key`'s audio has decoded (tests, offline rendering). pub fn wait_loaded(&self, key: u64, timeout: std::time::Duration) -> bool { let slot = self.with(|m| m.voices.iter().find(|v| v.key == key).and_then(|v| v.audio.clone())); @@ -459,6 +576,7 @@ mod tests { let mut m = Mixer { voices: vec![Voice { key: 1, + media_key: 1, audio: Some(slot), spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, t: 0.0, @@ -502,4 +620,206 @@ mod tests { let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); assert!((pos - 0.1).abs() < 1e-6, "{pos}"); } + + /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. + fn wav_level(secs: f64, amp: f32) -> Bytes { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + Arc::new(b) + } + + fn audible(duration: f64) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } + } + + /// First mixed sample of voice `key`, after its audio has decoded. + fn first_sample(p: &Player, key: u64) -> f32 { + assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + p.render(&mut buf, 1, 8_000); + buf[0] + } + + #[test] + fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { + let mut p = Player::new(None); + let loud = wav_level(0.05, 0.5); + let quiet = wav_level(0.10, 0.25); + let loud_key = Arc::as_ptr(&loud) as u64; + let quiet_key = Arc::as_ptr(&quiet) as u64; + assert_ne!(loud_key, quiet_key); + + // Duration 0: the clock takes its length from the decoded audio, so a stale slot would + // report the previous clip. + p.play(1, loud_key, &loud, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); + + // The caller's buffer can go away; the cache still owns the allocation its key names. + drop(loud); + assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); + + p.play(1, quiet_key, &quiet, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); + assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); + // Resuming the same source does not jump back to the start. + let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); + p.play(1, quiet_key, &quiet, audible(0.0), None); + let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); + } + + type Fill = Box; + + /// An output that starts, then fails or goes quiet on command. + #[derive(Clone, Default)] + struct Flaky { + fill: Arc>>, + fail: Arc>>, + starts: Arc>, + } + impl AudioOut for Flaky { + fn start(&mut self, fill: Fill) -> Result<(), String> { + *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); + *self.starts.lock().map_err(|e| e.to_string())? += 1; + Ok(()) + } + fn stop(&mut self) { + if let Ok(mut f) = self.fill.lock() { + *f = None; + } + } + fn failure(&mut self) -> Option { + self.fail.lock().ok().and_then(|mut f| f.take()) + } + } + impl Flaky { + fn pull(&self, frames: usize) { + let mut buf = vec![0.0; frames * 2]; + if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { + f(&mut buf, 2, 48_000); + } + } + fn starts(&self) -> u32 { + self.starts.lock().map(|s| *s).unwrap_or(0) + } + } + + fn pos(p: &Player, key: u64) -> f64 { + p.status(key).map(|s| s.position).unwrap_or(-1.0) + } + + #[test] + fn a_failed_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.tick(0.0); + out.pull(4800); + assert!((pos(&p, 1) - 0.1).abs() < 1e-6); + if let Ok(mut f) = out.fail.lock() { + *f = Some("unplugged".into()); + } + p.tick(0.2); + p.tick(0.4); + // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then + // the 0.4 s of wall time since the last tick before the failure. + assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); + // Playing again restarts the device. + p.play(2, 2, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 2); + assert!(p.has_output()); + } + + #[test] + fn a_stalled_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + // No callback ever arrives: after the first-callback grace the wall clock takes over. + for i in 0..=21 { + p.tick(i as f64 * 0.25); + } + let before = pos(&p, 1); + assert!(before > 0.0, "{before}"); + p.tick(5.5); + assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); + } + + #[test] + fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Two seconds in, the device starts asking for samples: the same stream keeps playing. + out.pull(256); + p.tick(2.25); + p.tick(2.5); + assert_eq!(out.starts(), 1, "the stream was not restarted"); + } + + #[test] + fn an_idle_stream_without_callbacks_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.pause(1); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Resuming keeps the running stream: no restart. + p.play(1, 1, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 1); + } + + #[test] + fn repeated_failures_give_up_on_the_device() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + let mut now = 0.0; + for _ in 0..MAX_OUTPUT_FAILURES { + p.play(1, 1, &b, spec(100.0, false), None); + if let Ok(mut f) = out.fail.lock() { + *f = Some("broken".into()); + } + p.tick(now); + now += 0.1; + } + assert!(!p.has_output()); + assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); + // Still plays, silently on wall time. + let before = pos(&p, 1); + p.tick(now + 0.2); + assert!(pos(&p, 1) > before); + } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 80a8a380..cf5de96a 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -58,8 +58,13 @@ pub fn probe(bytes: &Bytes) -> Result { }; if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) { // Plain audio files (and anything our demuxers didn't describe): symphonia's probe. - let (codec, rate, channels, frames) = - crate::audio::probe_symphonia(bytes, c.name()).ok_or_else(|| MediaError::Unsupported("this kind of media file".into()))?; + if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) { + return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())); + } + let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name())) + .ok() + .flatten() + .ok_or_else(|| MediaError::Unsupported("this kind of media file".into()))?; info.audio = Some(AudioInfo { codec: codec_label(&codec), sample_rate: rate, channels, decodable: true }); if info.duration_ms == 0 && let Some(n) = frames @@ -170,7 +175,7 @@ fn probe_ogg(bytes: &Bytes) -> Option { let s = f.stream_of(deckcraft_ogg::Codec::Opus)?; let st = f.streams.get(s)?; let head = deckcraft_opus::OpusHead::parse(st.headers.first()?).ok()?; - let timing = deckcraft_ogg::OpusTiming::of(st, head.pre_skip as u32); + let timing = deckcraft_ogg::OpusTiming::of(st, head.pre_skip as u32).ok()?; Some(MediaInfo { container: "ogg", duration_ms: ms(timing.total as f64 / 48_000.0), diff --git a/crates/media/tests/decode.rs b/crates/media/tests/decode.rs index b3179445..8393caa1 100644 --- a/crates/media/tests/decode.rs +++ b/crates/media/tests/decode.rs @@ -312,6 +312,23 @@ fn video_feed_follows_the_clock() { assert!(f.is_some_and(|v| (v - 0.5).abs() < 0.02), "{f:?}"); } +#[test] +fn video_feed_repeated_seeks_keep_the_requested_frame() { + let timeout = Duration::from_secs(5); + for name in ["clip-h264.mov", "clip-hevc.mp4", "clip-vp9.webm", "clip-av1.mkv"] { + let mut feed = deckcraft_media::VideoFeed::new(fixture(name)).expect("feed"); + for _ in 0..20 { + // A decoder can finish this small clip before the consumer next polls it. + // Rewinding must keep the new generation's first frame, even at end of stream. + feed.seek(0.05); + let frame = feed.frame_at_blocking(0.05, timeout).expect("frame after rewind"); + assert_eq!(frame.time, 0.0, "{name}"); + let frame = feed.frame_at_blocking(0.45, timeout).expect("frame after advancing"); + assert!((frame.time - 0.4).abs() < 0.02, "{name}: {}", frame.time); + } + } +} + #[test] fn player_plays_decoded_audio_through_the_mixer() { use deckcraft_media::{ClipParams, Player, VoiceSpec}; @@ -325,3 +342,48 @@ fn player_plays_decoded_audio_through_the_mixer() { assert!((rms - 0.0884 * 0.5).abs() < 0.005, "{rms}"); assert!((p.status(1).map(|s| s.position).unwrap_or(0.0) - 0.1).abs() < 1e-6); } + +/// Damaged files found by mutation fuzzing the fixtures above (so CC0 like them): each must come +/// back as an error or as audio — not panic (symphonia's Matroska reader panics on the first two) +/// and not hang (the MP4 demuxer reported the same error forever on the last two). +#[test] +fn damaged_media_neither_panics_nor_hangs() { + for name in ["damaged/ebml-header.mkv", "damaged/ebml-position.webm", "damaged/demux-loop.mp4", "damaged/demux-loop.m4a"] { + let b = fixture(name); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let ok = std::panic::catch_unwind(|| { + let _ = probe::probe(&b); + let _ = audio::decode(&b); + if let Ok(mut d) = video::VideoDecoder::open(b.clone()) { + for _ in 0..12 { + if !matches!(d.next_frame(), Ok(Some(_))) { + break; + } + } + } + }) + .is_ok(); + let _ = tx.send(ok); + }); + match rx.recv_timeout(Duration::from_secs(20)) { + Ok(ok) => assert!(ok, "{name} panicked"), + Err(_) => panic!("{name} still decoding after 20 s"), + } + } +} + +/// MP4s whose sample tables were damaged by a mutation fuzzer (from `tone.m4a`, so CC0 like it): +/// one claims billions of `stsc` entries, one a 4 GB sample. Symphonia reserved that much memory +/// up front and the process aborted; now they are corrupt files. +#[test] +fn mp4_sample_tables_that_overrun_the_file_are_corrupt() { + for name in ["damaged/stsc-count.m4a", "damaged/stsz-size.m4a"] { + let b = fixture(name); + assert!(matches!(audio::decode(&b), Err(MediaError::Corrupt(_))), "{name}"); + let _ = probe::probe(&b); + } + for name in ["tone.m4a", "tone-alac.m4a", "clip-h264.mp4", "clip-h264.mov", "clip-hevc.mp4"] { + assert!(audio::decode(&fixture(name)).is_ok() || name.starts_with("clip"), "{name} still decodes"); + } +} diff --git a/crates/media/tests/fixtures/damaged/demux-loop.m4a b/crates/media/tests/fixtures/damaged/demux-loop.m4a new file mode 100644 index 00000000..9033ab9a Binary files /dev/null and b/crates/media/tests/fixtures/damaged/demux-loop.m4a differ diff --git a/crates/media/tests/fixtures/damaged/demux-loop.mp4 b/crates/media/tests/fixtures/damaged/demux-loop.mp4 new file mode 100644 index 00000000..f6e6c0a2 Binary files /dev/null and b/crates/media/tests/fixtures/damaged/demux-loop.mp4 differ diff --git a/crates/media/tests/fixtures/damaged/ebml-header.mkv b/crates/media/tests/fixtures/damaged/ebml-header.mkv new file mode 100644 index 00000000..661a3177 Binary files /dev/null and b/crates/media/tests/fixtures/damaged/ebml-header.mkv differ diff --git a/crates/media/tests/fixtures/damaged/ebml-position.webm b/crates/media/tests/fixtures/damaged/ebml-position.webm new file mode 100644 index 00000000..da5b8005 Binary files /dev/null and b/crates/media/tests/fixtures/damaged/ebml-position.webm differ diff --git a/crates/media/tests/fixtures/damaged/stsc-count.m4a b/crates/media/tests/fixtures/damaged/stsc-count.m4a new file mode 100644 index 00000000..06cb3580 Binary files /dev/null and b/crates/media/tests/fixtures/damaged/stsc-count.m4a differ diff --git a/crates/media/tests/fixtures/damaged/stsz-size.m4a b/crates/media/tests/fixtures/damaged/stsz-size.m4a new file mode 100644 index 00000000..1e76492a Binary files /dev/null and b/crates/media/tests/fixtures/damaged/stsz-size.m4a differ diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index 63f7ee77..1b564539 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -11,3 +11,4 @@ workspace = true [dependencies] kurbo = { workspace = true } thiserror = { workspace = true } +image = { workspace = true } diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 4ffa56bf..f286dc61 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, OnceLock}; +use crate::clip::{self, MAX_CLIP_RECTS}; use crate::dib::Rgba; use crate::geom::{Op, P}; use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align}; @@ -16,7 +17,9 @@ const STOCK: u32 = 0x8000_0000; pub(crate) const PS_NULL: u32 = 5; /// Pen style bits; the rest of the style word holds end-cap, join and type flags. pub(crate) const PS_STYLE_MASK: u32 = 0x0F; -const BS_SOLID: u32 = 0; +pub(crate) const BS_SOLID: u32 = 0; +/// Cells on the longer side of a computed (gradient or texture) fill tile. +const SHADE_RES: usize = 128; pub(crate) const BS_NULL: u32 = 1; pub(crate) const BS_HATCHED: u32 = 2; /// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile. @@ -244,19 +247,39 @@ pub(crate) enum Raw { fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, + /// Device-space clip region. None is the default clip. + region: Option>, }, /// A path filled with a repeating tile. Pattern { ops: Vec, even_odd: bool, tile: Arc, + region: Option>, }, Bitmap { rect: [f64; 4], img: Rgba, + region: Option>, }, - /// Text whose transform and clip are in device units. - Text(Box), + /// Text whose transform and clip are in device units. `region` is the device clip. + Text { + text: Box, + region: Option>, + }, +} + +impl Raw { + fn set_region(&mut self, region: Option>) { + match self { + Raw::Path { region: slot, .. } + | Raw::Pattern { region: slot, .. } + | Raw::Bitmap { region: slot, .. } + | Raw::Text { region: slot, .. } => { + *slot = region; + } + } + } } /// Text drawing state: colours, background mode, alignment and the selected font. @@ -277,6 +300,7 @@ struct Saved { even_odd: bool, rop2: u32, text: TextState, + clip: Option>, } pub(crate) struct Canvas { @@ -304,6 +328,12 @@ pub(crate) struct Canvas { in_path: bool, path: Vec, full: bool, + /// Current clip in device units. None is the default (the whole surface); Some([]) draws nothing. + clip: Option>, + /// Device bounds of the picture, so excluding from the default clip has a surface to cut. + surface: Option<[f64; 4]>, + /// EMF+ convex device clip polygon: drawing is cut to it geometrically. + plus_clip: Option>, } impl Canvas { @@ -327,15 +357,39 @@ impl Canvas { in_path: false, path: Vec::new(), full: false, + clip: None, + surface: None, + plus_clip: None, } } + /// Device bounds `[x0, y0, x1, y1]` of the picture. Used when a clip record cuts the default clip. + pub fn set_clip_surface(&mut self, rect: [f64; 4]) { + self.surface = clip::normalize(rect); + } + /// True once the item cap is hit; the readers stop there and keep what they have. pub fn full(&self) -> bool { self.full } fn push(&mut self, raw: Raw) { + match self.plus_clip.take() { + None => self.push_raw(raw), + Some(c) => { + for r in crate::cliprect::clip_raw(raw, &c) { + self.push_raw(r); + } + self.plus_clip = Some(c); + } + } + } + + fn push_raw(&mut self, mut raw: Raw) { + if self.clip.as_ref().is_some_and(Vec::is_empty) { + return; + } + raw.set_region(self.clip.clone()); let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), // A tile shared with its brush is counted once (by the brush); one made for this fill @@ -345,7 +399,7 @@ impl Canvas { ops.len() * std::mem::size_of::() + 64 + own } Raw::Bitmap { img, .. } => img.px.len(), - Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128, + Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128, }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; @@ -442,6 +496,83 @@ impl Canvas { } } + /// The current world transform (EMF+ draws through it). + pub fn world(&self) -> Xform { + self.map.world + } + + /// Sets the pen and brush directly (EMF+ records name their own pen and brush). + pub fn set_pen(&mut self, pen: Pen) { + self.pen = pen; + } + + pub fn set_brush(&mut self, brush: Brush) { + self.brush = brush; + } + + /// Sets the EMF+ convex device clip polygon (None: unclipped). + pub fn set_clip(&mut self, clip: Option>) { + self.plus_clip = clip; + } + + /// Logical to device as one affine transform (the mapping is affine). + pub fn device_affine(&self) -> Xform { + let o = self.map.device((0.0, 0.0)); + let x = self.map.device((1.0, 0.0)); + let y = self.map.device((0.0, 1.0)); + [x.0 - o.0, x.1 - o.1, y.0 - o.0, y.1 - o.1, o.0, o.1] + } + + /// Text colour, font and alignment set directly (EMF+ strings name their own font and brush); + /// the background is transparent. + pub fn set_text_style(&mut self, color: [u8; 4], font: LogFont, align: u32) { + self.text.color = color; + self.text.bk_opaque = false; + self.text.align = align; + self.text.font = Some(Arc::new(font)); + } + + /// Pushes an item already in device units (a nested metafile's drawing). + pub fn push_device(&mut self, raw: Raw) { + self.push(raw); + } + + /// Fills logical `ops` with a ready-made tile, laid from the device origin. + pub fn tile_shape(&mut self, ops: &[Op], tile: Arc) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if dev.len() >= 2 { + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile, region: None }); + } + } + + /// Fills logical `ops` with a colour computed per device point (gradients, textures): a tile + /// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers + /// the shape. + pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return }; + let side = (r - l).max(b - t); + if dev.len() < 2 || !(side.is_finite() && side > 0.0) { + return; + } + let cell = side / SHADE_RES as f64; + let w = (((r - l) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let h = (((b - t) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let (pw, ph) = (w as f64 * cell, h as f64 * cell); + let mut px = Vec::with_capacity(w * h * 4); + for j in 0..h { + // The device position this tile row stands for inside the shape's box. + let y = t + (j as f64 * cell - t).rem_euclid(ph) + cell / 2.0; + for i in 0..w { + let x = l + (i as f64 * cell - l).rem_euclid(pw) + cell / 2.0; + px.extend_from_slice(&shade((x, y))); + } + } + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }), region: None }); + } + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores /// could no longer match the file. pub fn save_dc(&mut self) { @@ -453,6 +584,7 @@ impl Canvas { even_odd: self.even_odd, rop2: self.rop2, text: self.text.clone(), + clip: self.clip.clone(), }); } else { self.full = true; @@ -471,6 +603,61 @@ impl Canvas { self.even_odd = s.even_odd; self.rop2 = s.rop2; self.text = s.text; + self.clip = s.clip; + } + + /// The logical rectangle as a device rectangle. A scale or translation keeps the rectangle; a + /// rotation stores the bounds of the rotated corners. + fn device_rect(&self, [l, t, r, b]: [f64; 4]) -> Option<[f64; 4]> { + let corners = [(l, t), (r, t), (l, b), (r, b)].map(|p| self.map.device(p)); + if !corners.iter().all(|p| p.0.is_finite() && p.1.is_finite()) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in corners { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + } + clip::normalize([x0, y0, x1, y1]) + } + + fn map_rects(&self, logical: &[[f64; 4]]) -> Vec<[f64; 4]> { + let mut out = Vec::new(); + for r in logical.iter().take(MAX_CLIP_RECTS) { + if let Some(d) = self.device_rect(*r) { + out.push(d); + } + if out.len() >= MAX_CLIP_RECTS { + break; + } + } + out + } + + /// INTERSECTCLIPRECT: later drawing is limited to this logical rectangle as well. + pub fn intersect_clip_rect(&mut self, logical: [f64; 4]) { + self.clip = match self.device_rect(logical) { + Some(rect) => clip::intersect(self.clip.clone(), rect), + None => Some(Vec::new()), + }; + } + + /// EXCLUDECLIPRECT: this logical rectangle is removed from the clip. + pub fn exclude_clip_rect(&mut self, logical: [f64; 4]) { + let Some(rect) = self.device_rect(logical) else { return }; + self.clip = clip::exclude(self.clip.clone(), rect, self.surface); + } + + /// EXTSELECTCLIPRGN: combine these logical rectangles with the clip in a `RGN_*` mode. + pub fn select_clip_rects(&mut self, logical: &[[f64; 4]], mode: u32) { + self.clip = clip::combine(self.clip.clone(), self.map_rects(logical), mode, self.surface); + } + + /// The default clip: later records are not limited by a region. + pub fn reset_clip(&mut self) { + self.clip = None; } /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). @@ -603,14 +790,14 @@ impl Canvas { match fill { Some(Paint::Tile(tile)) => { if stroke.is_some() { - self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile }); - self.push(Raw::Path { ops, fill: None, stroke, even_odd }); + self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile, region: None }); + self.push(Raw::Path { ops, fill: None, stroke, even_odd, region: None }); } else { - self.push(Raw::Pattern { ops, even_odd, tile }); + self.push(Raw::Pattern { ops, even_odd, tile, region: None }); } } - Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }), - None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }), + Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd, region: None }), + None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd, region: None }), None => {} } } @@ -755,7 +942,7 @@ impl Canvas { /// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS). pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) { let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); - self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true }); + self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true, region: None }); } /// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS). @@ -831,17 +1018,21 @@ impl Canvas { } _ => None, }; - self.push(Raw::Text(Box::new(Text { - transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1], - text, - dx, - font: lf.font, - color: self.text.color, - background: self.text.bk_opaque.then_some(self.text.bk_color), - h_align, - v_align, - clip, - }))); + self.push(Raw::Text { + text: Box::new(Text { + transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1], + text, + dx, + font: lf.font, + color: self.text.color, + background: self.text.bk_opaque.then_some(self.text.bk_color), + h_align, + v_align, + clip, + region: None, + }), + region: None, + }); } /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the @@ -852,7 +1043,7 @@ impl Canvas { let b = self.map.device((x + w, y + h)); crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; - self.push(Raw::Bitmap { rect: dev, img }); + self.push(Raw::Bitmap { rect: dev, img, region: None }); } } diff --git a/crates/metafile/src/clip.rs b/crates/metafile/src/clip.rs new file mode 100644 index 00000000..2fc88ca4 --- /dev/null +++ b/crates/metafile/src/clip.rs @@ -0,0 +1,193 @@ +//! Clipping regions for EMF and WMF (MS-EMF 2.3.12, MS-WMF 2.3.5). +//! +//! A region is a set of axis-aligned rectangles in device units: `None` is the default clip (the +//! whole surface), `Some([])` draws nothing, and `Some(rects)` is their union. That is how GDI +//! stores a region (`RGNDATA` is a list of rectangles). Counts are capped so a hostile file cannot +//! grow the set without bound; past the cap, extra rectangles are dropped, which can only hide +//! drawing. + +/// Rectangles kept from one region. Past this, further rectangles are dropped. +pub(crate) const MAX_CLIP_RECTS: usize = 64; + +pub(crate) const RGN_AND: u32 = 1; +pub(crate) const RGN_OR: u32 = 2; +pub(crate) const RGN_XOR: u32 = 3; +pub(crate) const RGN_DIFF: u32 = 4; +pub(crate) const RGN_COPY: u32 = 5; + +/// A rectangle `[x0, y0, x1, y1]` with a positive area, or none. +pub(crate) fn normalize(rect: [f64; 4]) -> Option<[f64; 4]> { + let [l, t, r, b] = rect; + if ![l, t, r, b].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (l.min(r), l.max(r)); + let (y0, y1) = (t.min(b), t.max(b)); + (x1 - x0 > 1e-6 && y1 - y0 > 1e-6).then_some([x0, y0, x1, y1]) +} + +fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1])); + let (x1, y1) = (a[2].min(b[2]), a[3].min(b[3])); + (x1 - x0 > 1e-6 && y1 - y0 > 1e-6 && [x0, y0, x1, y1].iter().all(|v| v.is_finite())).then_some([x0, y0, x1, y1]) +} + +/// `a` with `b` removed, as up to four rectangles. +fn subtract_one(a: [f64; 4], b: [f64; 4]) -> Vec<[f64; 4]> { + let Some(i) = intersection(a, b) else { return vec![a] }; + let mut out = Vec::new(); + if i[1] > a[1] + && let Some(r) = normalize([a[0], a[1], a[2], i[1]]) + { + out.push(r); + } + if i[3] < a[3] + && let Some(r) = normalize([a[0], i[3], a[2], a[3]]) + { + out.push(r); + } + if i[0] > a[0] + && let Some(r) = normalize([a[0], i[1], i[0], i[3]]) + { + out.push(r); + } + if i[2] < a[2] + && let Some(r) = normalize([i[2], i[1], a[2], i[3]]) + { + out.push(r); + } + out +} + +fn cap(mut rects: Vec<[f64; 4]>) -> Vec<[f64; 4]> { + rects.truncate(MAX_CLIP_RECTS); + rects +} + +fn subtract_all(subject: Vec<[f64; 4]>, cuts: &[[f64; 4]]) -> Vec<[f64; 4]> { + let mut cur = subject; + for cut in cuts { + let mut next = Vec::new(); + for s in cur { + for piece in subtract_one(s, *cut) { + if next.len() >= MAX_CLIP_RECTS { + return next; + } + next.push(piece); + } + } + cur = next; + } + cur +} + +/// Intersects the current region with one rectangle. +pub(crate) fn intersect(current: Option>, rect: [f64; 4]) -> Option> { + let Some(rect) = normalize(rect) else { return Some(Vec::new()) }; + Some(match current { + None => vec![rect], + Some(rects) => cap(rects.iter().filter_map(|r| intersection(*r, rect)).collect()), + }) +} + +/// Removes one rectangle. `surface` is the device bounds, used when the clip is still the default +/// (the whole surface) so the remainder can be named. +pub(crate) fn exclude(current: Option>, rect: [f64; 4], surface: Option<[f64; 4]>) -> Option> { + let Some(rect) = normalize(rect) else { return current }; + match current { + Some(rects) => Some(subtract_all(rects, &[rect])), + None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &[rect])), + } +} + +/// Combines `incoming` with the current region in a region mode (`RGN_*`). An unknown mode leaves +/// the region as it was. `RGN_COPY` replaces it. An `RGN_OR` or `RGN_XOR` against the default clip +/// stays the default clip: the whole surface already covers the incoming rectangles. +pub(crate) fn combine(current: Option>, incoming: Vec<[f64; 4]>, mode: u32, surface: Option<[f64; 4]>) -> Option> { + let incoming: Vec<[f64; 4]> = cap(incoming.iter().copied().filter_map(normalize).collect()); + match mode { + RGN_COPY => Some(incoming), + RGN_AND => match current { + None => Some(incoming), + Some(rects) => { + let mut out = Vec::new(); + for a in &rects { + for b in &incoming { + if out.len() >= MAX_CLIP_RECTS { + return Some(out); + } + if let Some(r) = intersection(*a, *b) { + out.push(r); + } + } + } + Some(out) + } + }, + RGN_OR => match current { + None => None, + Some(mut rects) => { + rects.extend(incoming); + Some(cap(rects)) + } + }, + RGN_DIFF => match current { + Some(rects) => Some(subtract_all(rects, &incoming)), + None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &incoming)), + }, + RGN_XOR => { + let base = current?; + let mut out = subtract_all(base.clone(), &incoming); + let rest = subtract_all(incoming, &base); + for r in rest { + if out.len() >= MAX_CLIP_RECTS { + break; + } + out.push(r); + } + Some(out) + } + _ => current, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intersect_shrinks_to_the_overlap() { + let r = intersect(None, [0.0, 0.0, 100.0, 50.0]); + let r = intersect(r, [40.0, 10.0, 80.0, 40.0]); + assert_eq!(r, Some(vec![[40.0, 10.0, 80.0, 40.0]])); + assert_eq!(intersect(r, [0.0, 0.0, 10.0, 10.0]), Some(vec![])); + assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 20.0]]), [40.0, 0.0, 80.0, 20.0]), Some(vec![])); + } + + #[test] + fn modes_copy_and_or_diff_and_xor() { + let a = Some(vec![[0.0, 0.0, 100.0, 40.0]]); + assert_eq!(combine(a.clone(), vec![[10.0, 10.0, 30.0, 20.0]], RGN_COPY, None), Some(vec![[10.0, 10.0, 30.0, 20.0]])); + let and = combine(a.clone(), vec![[50.0, 0.0, 150.0, 40.0]], RGN_AND, None); + assert_eq!(and, Some(vec![[50.0, 0.0, 100.0, 40.0]])); + let or = combine(a.clone(), vec![[200.0, 0.0, 210.0, 10.0]], RGN_OR, None).unwrap(); + assert_eq!(or.len(), 2); + let diff = combine(a.clone(), vec![[20.0, 0.0, 40.0, 40.0]], RGN_DIFF, None).unwrap(); + assert_eq!(diff, vec![[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 100.0, 40.0]]); + let xor = combine(a, vec![[80.0, 0.0, 120.0, 40.0]], RGN_XOR, None).unwrap(); + assert!(xor.contains(&[0.0, 0.0, 80.0, 40.0])); + assert!(xor.contains(&[100.0, 0.0, 120.0, 40.0])); + } + + #[test] + fn excluding_from_the_default_clip_uses_the_surface() { + let r = exclude(None, [10.0, 0.0, 20.0, 40.0], Some([0.0, 0.0, 40.0, 40.0])).unwrap(); + assert_eq!(r, vec![[0.0, 0.0, 10.0, 40.0], [20.0, 0.0, 40.0, 40.0]]); + assert_eq!(exclude(None, [10.0, 0.0, 20.0, 40.0], None), None); + } + + #[test] + fn a_zero_area_rectangle_clears_an_intersection() { + assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 10.0]]), [5.0, 5.0, 5.0, 8.0]), Some(vec![])); + } +} diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs new file mode 100644 index 00000000..396f4402 --- /dev/null +++ b/crates/metafile/src/cliprect.rs @@ -0,0 +1,329 @@ +//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and +//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside +//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman +//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a +//! non-rectangular polygon) and text gets the polygon's bounding rectangle. + +use crate::canvas::Raw; +use crate::dib::Rgba; +use crate::geom::{Op, P}; + +/// Line segments per Bézier when a crossing shape is flattened. +const CURVE_STEPS: usize = 16; + +/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). +pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut add = |p: P| { + let v = b.get_or_insert([p.0, p.1, p.0, p.1]); + *v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]; + }; + for op in ops { + match *op { + Op::M(p) | Op::L(p) => add(p), + Op::C(a, c, d) => { + add(a); + add(c); + add(d); + } + Op::Z => {} + } + } + b +} + +pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + // An empty intersection stays empty (zero size) so it clips everything away. + if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r } +} + +/// A rectangle `[l, t, r, b]` as a polygon. +pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec

{ + vec![(l, t), (r, t), (r, b), (l, b)] +} + +/// The bounding box of a point list. +pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> { + let (&f, rest) = pts.split_first()?; + Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)])) +} + +fn cross(a: P, b: P, p: P) -> f64 { + (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0) +} + +/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or +/// degenerate); duplicate and collinear points are dropped. +pub(crate) fn convex(pts: &[P]) -> Option> { + let mut v: Vec

= Vec::with_capacity(pts.len()); + for &p in pts { + if !(p.0.is_finite() && p.1.is_finite()) { + return None; + } + if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) { + v.push(p); + } + } + while v.len() > 1 && v.first() == v.last() { + v.pop(); + } + let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum(); + if v.len() < 3 || area.abs() < 1e-12 { + return None; + } + if area < 0.0 { + v.reverse(); + } + let n = v.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]); + if c < -1e-9 * (1.0 + area.abs()) { + return None; + } + if c.abs() > 1e-12 { + out.push(v[i]); + } + } + (out.len() >= 3).then_some(out) +} + +fn inside_poly(clip: &[P], p: P) -> bool { + (0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9) +} + +/// Whether the clip polygon is an axis-aligned rectangle. +fn is_rect(clip: &[P]) -> bool { + pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3]))) +} + +/// Sutherland–Hodgman: `poly` cut by convex `clip`. +pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec

{ + let mut poly = poly.to_vec(); + for i in 0..clip.len() { + let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); + let keep = |p: P| cross(a, b, p) >= 0.0; + let cut = |p: P, q: P| { + let (cp, cq) = (cross(a, b, p), cross(a, b, q)); + let t = cp / (cp - cq); + (p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t) + }; + let mut next = Vec::with_capacity(poly.len() + 4); + for j in 0..poly.len() { + let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]); + match (keep(p), keep(q)) { + (true, true) => next.push(q), + (true, false) => next.push(cut(p, q)), + (false, true) => { + next.push(cut(p, q)); + next.push(q); + } + (false, false) => {} + } + } + poly = next; + if poly.is_empty() { + break; + } + } + poly +} + +fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { + b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1] +} + +fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { + rect_poly(b).into_iter().all(|p| inside_poly(clip, p)) +} + +/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes +/// back as two items. An empty polygon clips everything away. +pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { + let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; + match raw { + Raw::Path { ops, fill, stroke, even_odd, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let subs = flatten(&ops); + let mut out = Vec::new(); + if fill.is_some() { + let f = clip_fill(&subs, clip); + if !f.is_empty() { + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); + } + } + if stroke.is_some() { + let s = clip_stroke(&subs, clip); + if !s.is_empty() { + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); + } + } + out + } + Raw::Pattern { ops, even_odd, tile, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Pattern { ops, even_odd, tile, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let f = clip_fill(&flatten(&ops), clip); + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } + } + Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { + // Text clips to a rectangle: the one around the polygon. + let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); + t.clip = Some(cur.map(|v| v as f32)); + vec![Raw::Text { text: t, region }] + } + } +} + +/// Subpaths as point lists with their closed flag. +pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { + let mut subs: Vec<(Vec

, bool)> = Vec::new(); + for op in ops { + match *op { + Op::M(p) => subs.push((vec![p], false)), + Op::L(p) => match subs.last_mut() { + Some(s) => s.0.push(p), + None => subs.push((vec![p], false)), + }, + Op::C(a, b, d) => { + let Some(s) = subs.last_mut() else { continue }; + let Some(&p0) = s.0.last() else { continue }; + for i in 1..=CURVE_STEPS { + let t = i as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1)); + } + } + Op::Z => { + if let Some(s) = subs.last_mut() { + s.1 = true; + // Drawing after a close starts from the subpath's first point. + let first = s.0.first().copied(); + if let Some(f) = first { + subs.push((vec![f], false)); + } + } + } + } + } + subs.retain(|s| s.0.len() > 1); + subs +} + +fn clip_fill(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { + let mut ops = Vec::new(); + for (pts, _) in subs { + let poly = clip_polygon(pts, clip); + if poly.len() >= 3 { + ops.push(Op::M(poly[0])); + ops.extend(poly[1..].iter().map(|p| Op::L(*p))); + ops.push(Op::Z); + } + } + ops +} + +fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { + let mut ops = Vec::new(); + for (pts, closed) in subs { + let mut pts = pts.clone(); + if *closed && let Some(&f) = pts.first() { + pts.push(f); + } + let mut last: Option

= None; + for w in pts.windows(2) { + let Some((a, b)) = segment(w[0], w[1], clip) else { + last = None; + continue; + }; + if last != Some(a) { + ops.push(Op::M(a)); + } + ops.push(Op::L(b)); + last = Some(b); + } + } + ops +} + +/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`. +fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> { + let d = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for i in 0..clip.len() { + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + // Inside is cross(e0, e1, p) >= 0, linear in t along the segment. + let f0 = cross(e0, e1, a); + let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0; + if df == 0.0 { + if f0 < 0.0 { + return None; + } + } else { + let t = -f0 / df; + if df > 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + (t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))) +} + +/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip +/// polygon's box; pixels outside a non-rectangular polygon turn transparent. +fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> { + let [x, y, w, h] = rect; + let b = [x, y, x + w, y + h]; + if bbox_inside(b, clip) { + return Some((rect, img)); + } + let c = pts_bbox(clip)?; + if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 { + return None; + } + let i = intersect(b, c); + let (kx, ky) = (img.w as f64 / w, img.h as f64 / h); + let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w); + let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w); + let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h); + let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h); + if x1 <= x0 || y1 <= y0 { + return None; + } + let mask = !is_rect(clip); + let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + for row in y0..y1 { + // Rows are top-down unless the image says otherwise. + let r = if img.bottom_up { img.h - 1 - row } else { row }; + let s = (r * img.w + x0) * 4; + let start = px.len(); + px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?); + if mask { + let cy = y + (row as f64 + 0.5) / ky; + for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() { + let cx = x + ((x0 + k) as f64 + 0.5) / kx; + if !inside_poly(clip, (cx, cy)) { + p[3] = 0; + } + } + } + } + let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky]; + Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px })) +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 709537f7..94c6d605 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -39,9 +39,14 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { let [l, t, r, b] = bounds; let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None })); let mut cv = Canvas::new(); + if r > l && b > t { + cv.set_clip_surface([l, t, r, b]); + } if let Some(px_mm) = device_px_per_mm(input) { cv.set_device_mm(px_mm); } + let mut plus = crate::emfplus::Plus::new(); + plus.frame = frame.as_ref().map(|f| f.rect); let mut parsed = 0usize; let mut pos = hsize.max(88); while !cv.full() { @@ -54,7 +59,13 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { if typ == 14 { break; } - record(&mut cv, typ, Bytes(rec)); + if typ == 70 && Bytes(rec).u32(12) == Some(crate::emfplus::EMF_PLUS) { + if let Some(data) = Bytes(rec).tail(16) { + plus.comment(&mut cv, data); + } + } else { + record(&mut cv, typ, Bytes(rec)); + } parsed += 1; let Some(next) = pos.checked_add(size) else { break }; pos = next; @@ -117,6 +128,9 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 83 | 84 => ext_text_out(cv, r, typ == 84), 108 => small_text_out(cv, r), 40 => r.u32(8).map(|i| cv.delete_object(i)), + 29 => rect32(r, 8).map(|rect| cv.exclude_clip_rect(rect)), + 30 => rect32(r, 8).map(|rect| cv.intersect_clip_rect(rect)), + 75 => ext_select_clip(cv, r), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), @@ -182,6 +196,37 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { }; } +/// EMR_EXTSELECTCLIPRGN: `cbRgnData` bytes of `RGNDATA` (rectangles in logical units) and a region +/// mode. No region data selects the default clip. +fn ext_select_clip(cv: &mut Canvas, r: Bytes) -> Option<()> { + let cb = r.u32(8)? as usize; + let mode = r.u32(12)?; + if cb == 0 { + cv.reset_clip(); + return Some(()); + } + let header = 16; + if cb < 32 { + return Some(()); + } + // RDH_RECTANGLES is the only region type GDI writes. + if r.u32(header + 4)? != 1 { + return Some(()); + } + let n = (r.u32(header + 8)? as usize).min(crate::clip::MAX_CLIP_RECTS); + let end = header.checked_add(cb)?; + let mut rects = Vec::new(); + for i in 0..n { + let o = header + 32 + i * 16; + if o + 16 > end { + break; + } + rects.push(rect32(r, o)?); + } + cv.select_clip_rects(&rects, mode); + Some(()) +} + /// Two little-endian i32 values at `off`, as a point. fn xy32(r: Bytes, off: usize) -> Option

{ Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?))) diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs new file mode 100644 index 00000000..550c2d18 --- /dev/null +++ b/crates/metafile/src/emfplus.rs @@ -0,0 +1,1439 @@ +//! EMF+ (MS-EMFPLUS): GDI+ drawing records carried in EMF comment records. +//! +//! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records, which +//! [`crate::emf`] already plays, so this reader ignores their EMF+ part. An *EMF+ only* file has +//! nothing else to draw; for those this reader plays the records: +//! - brushes (solid, hatch, texture, linear and path gradients), pens, paths, images, fonts, +//! string formats and regions, including objects split across several records; +//! - rectangles, polygons, lines, ellipses, paths and Béziers; +//! - bitmaps (raw pixel formats, and PNG/JPEG/GIF/TIFF/BMP payloads) and strings (DrawString, +//! DrawDriverString with character codes); +//! - world and page transforms, save/restore and containers; +//! - arcs and pies; +//! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts; +//! - cardinal splines (DrawCurve, DrawClosedCurve, FillClosedCurve), FillRegion, and Clear +//! (over the picture frame). +//! +//! Every drawing record of MS-EMFPLUS §2.3.4 is played. Not drawn: image attributes, glyph +//! indices of other fonts, and region combinations other than intersect and union (XOR, exclude +//! and complement keep the left area). Deliberately ignored: rendering hints (antialias, text +//! hint, interpolation, pixel offset, compositing; the renderer has its own), SetRenderingOrigin +//! (hatch phase), SerializableObject image effects, the terminal-server records SetTSGraphics and +//! SetTSClip, and StrokeFillPath (0x4037), which the specification lists but gives no layout for +//! and GDI+ doesn't write. + +use std::sync::Arc; + +use crate::bytes::Bytes; +use crate::canvas::Raw; +use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; +use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly}; +use crate::dib::Rgba; +use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, Font}; +use crate::{Item, Picture, Seg}; + +/// "EMF+" as the comment identifier of an EMR_COMMENT holding EMF+ records. +pub(crate) const EMF_PLUS: u32 = 0x2B46_4D45; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; +/// GDI+ allows 64 objects; ids are a byte. +const MAX_OBJECTS: usize = 64; +const MAX_STACK: usize = 64; +/// Points per path or point list read, as the plain EMF reader caps paths. +const MAX_POINTS: usize = 100_000; +/// Gradient stops read from a brush. +const MAX_STOPS: usize = 256; +/// Characters per string. +const MAX_CHARS: usize = 32_768; +/// Bytes gathered for one object split across records. +const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024; +/// Nesting depth of region nodes. +const MAX_REGION_DEPTH: usize = 32; + +const TA_RIGHT: u32 = 2; +const TA_CENTER: u32 = 6; +const TA_BOTTOM: u32 = 8; +const TA_BASELINE: u32 = 24; + +/// What a brush paints. Coordinates are in brush space, which `xf` maps to world space. +#[derive(Clone)] +enum Fill { + Solid([u8; 4]), + Hatch { style: u32, fg: [u8; 4], bg: [u8; 4] }, + Linear { rect: [f64; 4], stops: Vec<(f64, [u8; 4])>, wrap: u32, xf: Xform }, + Radial { center: P, radius: P, stops: Vec<(f64, [u8; 4])>, xf: Xform }, + Texture { img: Arc, wrap: u32, xf: Xform }, +} + +impl Fill { + /// One colour standing for the brush (pens and text draw solid). + fn color(&self) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { stops, .. } | Fill::Radial { stops, .. } => ramp(stops, 0.5), + Fill::Texture { img, .. } => img.px.get(..4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 255]), + } + } + + /// The colour at a brush-space point. + fn sample(&self, p: P) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { rect, stops, wrap, .. } => { + let t = if rect[2] == 0.0 { 0.0 } else { (p.0 - rect[0]) / rect[2] }; + ramp(stops, wrap_t(t, *wrap)) + } + Fill::Radial { center, radius, stops, .. } => { + let (dx, dy) = ((p.0 - center.0) / radius.0.max(1e-9), (p.1 - center.1) / radius.1.max(1e-9)); + ramp(stops, dx.hypot(dy).min(1.0)) + } + Fill::Texture { img, wrap, .. } => { + let (w, h) = (img.w as f64, img.h as f64); + if w == 0.0 || h == 0.0 { + return [0, 0, 0, 0]; + } + if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) { + return [0, 0, 0, 0]; + } + let x = (p.0.rem_euclid(w) as usize).min(img.w - 1); + let y = (p.1.rem_euclid(h) as usize).min(img.h - 1); + let i = (y * img.w + x) * 4; + img.px.get(i..i + 4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 0]) + } + } + } + + fn xf(&self) -> Xform { + match self { + Fill::Linear { xf, .. } | Fill::Radial { xf, .. } | Fill::Texture { xf, .. } => *xf, + _ => IDENTITY, + } + } +} + +/// Where `t` lands under a wrap mode: tiled, mirrored (flip modes) or clamped. +fn wrap_t(t: f64, wrap: u32) -> f64 { + if !t.is_finite() { + return 0.0; + } + match wrap { + 1 | 3 => { + let m = t.rem_euclid(2.0); + if m > 1.0 { 2.0 - m } else { m } + } + 4 => t.clamp(0.0, 1.0), + _ => t.rem_euclid(1.0), + } +} + +/// The colour at `t` along sorted stops. +fn ramp(stops: &[(f64, [u8; 4])], t: f64) -> [u8; 4] { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0, 0, 0, 0] }; + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let k = if b > a { (t - a) / (b - a) } else { 0.0 }; + return lerp(ca, cb, k); + } + } + last.1 +} + +fn lerp(a: [u8; 4], b: [u8; 4], k: f64) -> [u8; 4] { + let mut c = [0u8; 4]; + for i in 0..4 { + c[i] = (f64::from(a[i]) + (f64::from(b[i]) - f64::from(a[i])) * k).round().clamp(0.0, 255.0) as u8; + } + c +} + +#[derive(Clone)] +struct FontObj { + em: f64, + unit: u32, + style: u32, + family: String, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Format { + flags: u32, + align: u32, + line_align: u32, +} + +/// A clip area: a convex polygon (empty clips everything), or None for infinite. +type Area = Option>; + +enum ImageData { + Bitmap(Rgba), + /// A nested WMF or EMF (EMF+) picture. + Meta(Picture), +} + +thread_local! { + /// Nesting depth of metafile images being parsed, so a file of nested metafiles cannot run + /// the stack out. + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +/// Metafile images inside metafile images, at most. +const MAX_NESTING: usize = 3; + +#[derive(Clone)] +enum Obj { + Brush(Fill), + Pen { + color: [u8; 4], + width: f64, + }, + Path(Vec), + Image(Arc), + Font(FontObj), + Format(Format), + /// A region's bounding rectangle `[l, t, r, b]` in world units; None is infinite. + Region(Area), +} + +/// An object arriving in several records: its id, type, total size and the bytes so far. +struct Partial { + id: usize, + kind: u16, + total: usize, + data: Vec, +} + +struct Saved { + id: u32, + world: Xform, + page: f64, + clip: Area, +} + +pub(crate) struct Plus { + /// An EMF+ header said the file is EMF+ only, so these records are the picture. + active: bool, + dpi: f64, + world: Xform, + page: f64, + /// Convex device clip polygon; None is unclipped. + clip: Area, + objects: Vec>, + stack: Vec, + partial: Option, + /// The picture frame in device units (`[x, y, w, h]`), what Clear paints. + pub frame: Option<[f64; 4]>, +} + +impl Plus { + pub fn new() -> Self { + Plus { + active: false, + dpi: 96.0, + world: IDENTITY, + page: 1.0, + clip: None, + objects: vec![None; MAX_OBJECTS], + stack: Vec::new(), + partial: None, + frame: None, + } + } + + /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). + pub fn comment(&mut self, cv: &mut Canvas, data: Bytes) { + let mut pos = 0usize; + while !cv.full() { + let (Some(typ), Some(flags), Some(size), Some(dsize)) = (data.u16(pos), data.u16(pos + 2), data.u32(pos + 4), data.u32(pos + 8)) else { + break; + }; + let (size, dsize) = (size as usize, dsize as usize); + if size < 12 || dsize > size - 12 { + break; + } + let Some(rec) = data.slice(pos + 12, dsize) else { break }; + let _ = self.record(cv, typ, flags, Bytes(rec)); + let Some(next) = pos.checked_add(size) else { break }; + pos = next; + } + } + + /// World units to device pixels before the canvas's own mapping. + fn xf(&self) -> Xform { + mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]) + } + + fn painter(&self) -> Painter { + Painter { xf: self.xf(), clip: self.clip.clone() } + } + + fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + if typ == 0x4001 { + // Header: dual files also draw with EMF records, which win. + self.active = flags & 1 == 0; + if let Some(d) = r.u32(8).map(f64::from).filter(|d| *d > 0.0 && *d < 100_000.0) { + self.dpi = d; + } + return Some(()); + } + if !self.active { + return None; + } + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { + 0x4008 => self.object(flags, r), + 0x400A => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let p = self.painter(); + for rc in rects(r, 8, r.u32(4)? as usize, compressed)? { + p.fill(cv, &rect_ops(rc), &fill); + } + Some(()) + } + 0x400B => { + let pen = self.pen(id)?; + let p = self.painter(); + for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { + p.stroke(cv, &rect_ops(rc), pen); + } + Some(()) + } + 0x400C => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().fill(cv, &poly_ops(&pts, true), &fill); + Some(()) + } + 0x400D => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + self.painter().stroke(cv, &poly_ops(&pts, flags & 0x2000 != 0), pen); + Some(()) + } + 0x400E => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let rect = *rects(r, 4, 1, compressed)?.first()?; + self.painter().fill(cv, &ellipse_ops(rect), &fill); + Some(()) + } + 0x400F => { + let pen = self.pen(id)?; + let rect = *rects(r, 0, 1, compressed)?.first()?; + self.painter().stroke(cv, &ellipse_ops(rect), pen); + Some(()) + } + 0x4014 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let ops = self.path(id)?; + self.painter().fill(cv, &ops, &fill); + Some(()) + } + 0x4015 => { + let ops = self.path(id)?; + let pen = self.pen(r.u32(0)? as usize)?; + self.painter().stroke(cv, &ops, pen); + Some(()) + } + 0x4019 => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + let (first, rest) = pts.split_first()?; + let mut ops = vec![Op::M(*first)]; + ops.extend(rest.as_chunks::<3>().0.iter().map(|c| Op::C(c[0], c[1], c[2]))); + self.painter().stroke(cv, &ops, pen); + Some(()) + } + // Cardinal splines: FillClosedCurve, DrawClosedCurve, DrawCurve. + 0x4016 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let tension = finite(r.f32(4)?)?; + let pts = points(r, 12, r.u32(8)? as usize, compressed)?; + self.painter().fill(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), &fill); + Some(()) + } + 0x4017 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), pen); + Some(()) + } + 0x4018 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let (offset, segments) = (r.u32(4)? as usize, r.u32(8)? as usize); + let pts = points(r, 16, r.u32(12)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); + Some(()) + } + // Clear: the whole picture in one colour, whatever the clip (a transparent clear + // leaves nothing to draw). + 0x4009 => { + let c = argb(r.u32(0)?); + let [x, y, w, h] = self.frame?; + if c[3] == 0 || !(w > 0.0 && h > 0.0) { + return Some(()); + } + let to_device = invert(mul(cv.world(), cv.device_affine()))?; + let p = Painter { xf: to_device, clip: None }; + p.fill(cv, &rect_ops([x, y, x + w, y + h]), &Fill::Solid(c)); + Some(()) + } + // FillRegion: the region's area (as the clip reads it). + 0x4013 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let Some(Some(Obj::Region(Some(area)))) = self.objects.get(id).cloned() else { return None }; + if area.len() < 3 { + return None; + } + self.painter().fill(cv, &poly_ops(&area, true), &fill); + Some(()) + } + 0x401A => { + let img = self.image(id)?; + let dst = *rects(r, 24, 1, compressed)?.first()?; + self.draw_image(cv, &img, r, [dst[0], dst[1], dst[2] - dst[0], dst[3] - dst[1]]) + } + 0x401B => { + let img = self.image(id)?; + let pts = points(r, 28, 3, compressed)?; + let (p0, p1, p2) = (*pts.first()?, *pts.get(1)?, *pts.get(2)?); + // The destination parallelogram's top-left, top-right and bottom-left corners; a + // sheared or rotated one is drawn into its bounding rectangle's axes. + let dst = [p0.0, p0.1, p1.0 - p0.0 + (p2.0 - p0.0), p2.1 - p0.1 + (p1.1 - p0.1)]; + self.draw_image(cv, &img, r, dst) + } + 0x401C => self.draw_string(cv, flags, r), + 0x4036 => self.draw_driver_string(cv, flags, r), + 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), + 0x4027 => r.u32(24).map(|i| self.push(i)), + 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), + 0x402A => matrix(r, 0).map(|m| self.world = m), + 0x402B => { + self.world = IDENTITY; + Some(()) + } + 0x402C => matrix(r, 0).and_then(|m| self.apply(m, flags)), + 0x402D => self.apply([1.0, 0.0, 0.0, 1.0, f64::from(r.f32(0)?), f64::from(r.f32(4)?)], flags), + 0x402E => self.apply([f64::from(r.f32(0)?), 0.0, 0.0, f64::from(r.f32(4)?), 0.0, 0.0], flags), + 0x402F => { + let a = f64::from(r.f32(0)?).to_radians(); + self.apply([a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0], flags) + } + 0x4030 => { + let page = f64::from(r.f32(0)?) * unit_px(u32::from(flags & 0xFF), self.dpi); + if page.is_finite() && page > 0.0 && page < 1.0e6 { + self.page = page; + } + Some(()) + } + 0x4031 => { + self.clip = None; + Some(()) + } + 0x4032 => { + let [x, y, w, h] = floats::<4>(r, 0)?; + let area = map_area(Some(rect_poly([x, y, x + w, y + h])), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4033 => { + let area = map_area(Some(area_of_ops(&self.path(id)?)), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4034 => { + let Some(Some(Obj::Region(reg))) = self.objects.get(id).cloned() else { return None }; + let area = map_area(reg, mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4035 => { + let [dx, dy] = floats::<2>(r, 0)?; + let a = mul(self.xf(), cv.device_affine()); + let (ox, oy) = (dx * a[0] + dy * a[2], dx * a[1] + dy * a[3]); + if let Some(c) = self.clip.as_mut() { + for p in c.iter_mut() { + *p = (p.0 + ox, p.1 + oy); + } + } + Some(()) + } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } + _ => None, + } + } + + /// Without the `A` flag a transform is applied before the world transform (prepended). + fn apply(&mut self, m: Xform, flags: u16) -> Option<()> { + self.world = if flags & 0x2000 != 0 { mul(self.world, m) } else { mul(m, self.world) }; + Some(()) + } + + fn push(&mut self, id: u32) { + if self.stack.len() < MAX_STACK { + self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip.clone() }); + } + } + + fn pop(&mut self, id: u32) { + if let Some(i) = self.stack.iter().rposition(|s| s.id == id) { + let s = self.stack.swap_remove(i); + (self.world, self.page, self.clip) = (s.world, s.page, s.clip); + self.stack.truncate(i); + } + } + + /// Combines a new clip area with the current one under the record's combine mode: replace, + /// intersect and union (as the rectangle around both). XOR, exclude and complement leave the + /// clip as it was. + fn combine(&mut self, flags: u16, new: Area) -> Option<()> { + self.clip = match (flags >> 8) & 0xF { + 0 => new, + 1 => area_intersect(self.clip.take(), new), + 2 => area_union(self.clip.take(), new), + _ => self.clip.take(), + }; + Some(()) + } + + fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + let kind = (flags >> 8) & 0x7F; + if flags & 0x8000 != 0 { + // A continued object: the total size, then this record's share of the bytes. + let total = (r.u32(0)? as usize).min(MAX_OBJECT_BYTES); + let chunk = r.tail(4)?.0; + let p = match self.partial.take() { + Some(p) if p.id == id && p.kind == kind && p.total == total => p, + _ => Partial { id, kind, total, data: Vec::new() }, + }; + let mut p = p; + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&chunk[..chunk.len().min(room)]); + if p.data.len() >= p.total { + let obj = parse_object(p.kind, Bytes(&p.data)); + self.store(p.id, obj); + } else { + self.partial = Some(p); + } + return Some(()); + } + // The last piece of a continued object comes without the continue flag. + if let Some(mut p) = self.partial.take() + && p.id == id + && p.kind == kind + { + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&r.0[..r.0.len().min(room)]); + let obj = parse_object(kind, Bytes(&p.data)); + self.store(id, obj); + return Some(()); + } + let obj = parse_object(kind, r); + self.store(id, obj); + Some(()) + } + + fn store(&mut self, id: usize, obj: Option) { + if let Some(slot) = self.objects.get_mut(id) { + *slot = obj; + } + } + + fn path(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Path(ops)) => Some(ops.clone()), + _ => None, + } + } + + fn pen(&self, id: usize) -> Option<([u8; 4], f64)> { + match self.objects.get(id)? { + Some(Obj::Pen { color, width }) => Some((*color, *width)), + _ => None, + } + } + + fn image(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Image(img)) => Some(img.clone()), + _ => None, + } + } + + /// The brush of a fill record: an ARGB value with the `S` flag, else a brush object. + fn brush_of(&self, flags: u16, v: u32) -> Option { + if flags & 0x8000 != 0 { + return Some(Fill::Solid(argb(v))); + } + match self.objects.get(v as usize)? { + Some(Obj::Brush(f)) => Some(f.clone()), + _ => None, + } + } + + /// DrawImage and DrawImagePoints: the source rectangle (pixels) cut from the image, drawn + /// into world rectangle `dst` = `[x, y, w, h]`. + fn draw_image(&self, cv: &mut Canvas, img: &ImageData, r: Bytes, dst: [f64; 4]) -> Option<()> { + let img = match img { + ImageData::Bitmap(b) => b, + ImageData::Meta(pic) => { + self.painter().picture(cv, dst, pic); + return Some(()); + } + }; + let [sx, sy, sw, sh] = floats::<4>(r, 8)?; + let src = if r.u32(4)? == 2 { [sx, sy, sw, sh].map(|v| v.round().clamp(-1.0e9, 1.0e9) as i64) } else { [0; 4] }; + let (part, dst) = crate::dib::clip_blit(img, src, dst)?; + self.painter().image(cv, dst, part); + Some(()) + } + + fn font(&self, id: usize) -> Option { + match self.objects.get(id)? { + Some(Obj::Font(f)) => Some(f.clone()), + _ => None, + } + } + + /// The font as a LOGFONT whose height is in world units. + fn log_font(&self, f: &FontObj) -> LogFont { + let em = if f.unit == 0 { f.em } else { f.em * unit_px(f.unit, self.dpi) / self.page }; + let font = Font { + family: f.family.clone(), + height: -(em.clamp(0.0, 1.0e6) as f32), + weight: if f.style & 1 != 0 { 700 } else { 400 }, + italic: f.style & 2 != 0, + underline: f.style & 4 != 0, + strike: f.style & 8 != 0, + ..Font::default() + }; + LogFont { font, escapement: 0.0 } + } + + /// DrawString: lines of text laid out in a rectangle by the string format's alignments. + fn draw_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let fmt = match self.objects.get(r.u32(4)? as usize) { + Some(Some(Obj::Format(f))) => *f, + _ => Format::default(), + }; + let len = (r.u32(8)? as usize).min(MAX_CHARS); + let [x, y, w, h] = floats::<4>(r, 12)?; + let units: Vec = (0..len).map(|i| r.u16(28 + i * 2)).collect::>()?; + let lf = self.log_font(&font); + let em = f64::from(-lf.font.height); + let wrap = w > 0.0 && fmt.flags & 0x1000 == 0; + let lines: Vec> = units + .split(|u| *u == 0x0A) + .flat_map(|l| { + let l: Vec = l.iter().copied().filter(|u| *u != 0x0D).collect(); + if wrap { wrap_line(&l, w, em) } else { vec![l] } + }) + .collect(); + let line_h = em * 1.2; + let (ax, ta_h) = match fmt.align { + 1 => (x + w / 2.0, TA_CENTER), + 2 => (x + w, TA_RIGHT), + _ => (x, 0), + }; + let block = line_h * lines.len() as f64; + let top = match fmt.line_align { + 1 => y + (h - block) / 2.0, + 2 => y + h - block, + _ => y, + }; + let clip = (w > 0.0 && h > 0.0 && fmt.flags & 0x4000 == 0).then_some([x, y, x + w, y + h]); + let p = self.painter(); + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + let (text, dx) = crate::text::decode_utf16(line, &[]); + let at = (ax, top + line_h * i as f64); + let (at, ta) = if fmt.line_align == 2 && lines.len() == 1 { ((ax, y + h), ta_h | TA_BOTTOM) } else { (at, ta_h) }; + p.text(cv, at, text, dx, TextStyle { color, font: lf.clone(), align: ta, clip }, IDENTITY); + } + Some(()) + } + + /// DrawDriverString with character codes (CmapLookup): positioned glyphs on a baseline. + fn draw_driver_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let opts = r.u32(4)?; + let n = (r.u32(12)? as usize).min(MAX_CHARS); + let mut units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + if opts & 1 == 0 { + // Glyph indices into the font. The crate knows no fonts, but the classic Windows core + // fonts keep the standard Macintosh glyph order for ASCII, so those map back. + units = glyphs_to_chars(&font.family, &units)?; + } + let pos = points(r, 16 + n * 2, n, false)?; + let m = if r.u32(8)? != 0 { matrix(r, 16 + n * 10)? } else { IDENTITY }; + let lf = self.log_font(&font); + let first = *pos.first()?; + let dx: Vec = if opts & 4 != 0 { + Vec::new() + } else { + let mut d: Vec = pos.windows(2).map(|w| (w[1].0 - w[0].0) as f32).collect(); + d.push(d.last().copied().unwrap_or(-lf.font.height * 0.5)); + d + }; + let (text, dx) = crate::text::decode_utf16(&units, &dx); + self.painter().text(cv, first, text, dx, TextStyle { color, font: lf, align: TA_BASELINE, clip: None }, m); + Some(()) + } +} + +/// Page unit to device pixels: world, display and pixel are 1; point, inch, document, mm. +fn unit_px(unit: u32, dpi: f64) -> f64 { + match unit { + 3 => dpi / 72.0, + 4 => dpi, + 5 => dpi / 300.0, + 6 => dpi / 25.4, + _ => 1.0, + } +} + +struct TextStyle { + color: [u8; 4], + font: LogFont, + align: u32, + /// World clip rectangle `[l, t, r, b]`. + clip: Option<[f64; 4]>, +} + +/// Draws through the canvas with EMF+'s transform, clip and its own pen or brush, then puts the +/// canvas's GDI state back. +struct Painter { + xf: Xform, + clip: Area, +} + +impl Painter { + fn begin(&self, cv: &mut Canvas, pre: Xform) { + let world = cv.world(); + cv.save_dc(); + cv.modify_world(mul(mul(pre, self.xf), world), 4); + cv.set_clip(self.clip.clone()); + } + + fn end(&self, cv: &mut Canvas) { + cv.restore_dc(-1); + cv.set_clip(None); + } + + fn no_pen(cv: &mut Canvas) { + cv.set_pen(Pen { style: PS_NULL, width: 0.0, color: [0, 0, 0, 0] }); + } + + fn fill(&self, cv: &mut Canvas, ops: &[Op], fill: &Fill) { + self.begin(cv, IDENTITY); + Self::no_pen(cv); + match fill { + Fill::Solid(c) => { + cv.set_brush(Brush::new(BS_SOLID, *c)); + cv.shape(ops, true, false); + } + Fill::Hatch { style, fg, bg } => match hatch_tile(*style, *fg, Some(*bg)) { + Some(px) => cv.tile_shape(ops, Arc::new(RawTile { w: 8, h: 8, px, cell: cv.pattern_px })), + // GDI+ has many more hatch styles than GDI: those fill with the blend of both colours. + None => { + cv.set_brush(Brush::new(BS_SOLID, lerp(*fg, *bg, 0.5))); + cv.shape(ops, true, false); + } + }, + _ => { + // Device point to brush space: invert brush space → world → device. + if let Some(inv) = invert(mul(fill.xf(), cv.device_affine())) { + cv.shade_shape(ops, &|p| fill.sample(apply(inv, p))); + } + } + } + self.end(cv); + } + + fn stroke(&self, cv: &mut Canvas, ops: &[Op], (color, width): ([u8; 4], f64)) { + self.begin(cv, IDENTITY); + cv.set_pen(Pen { style: 0, width, color }); + cv.set_brush(Brush::new(BS_NULL, [0, 0, 0, 0])); + cv.shape(ops, false, true); + self.end(cv); + } + + fn image(&self, cv: &mut Canvas, dst: [f64; 4], img: Rgba) { + self.begin(cv, IDENTITY); + cv.bitmap(dst, img); + self.end(cv); + } + + /// A nested metafile's picture stretched into world rectangle `dst` = `[x, y, w, h]`. + fn picture(&self, cv: &mut Canvas, [x, y, w, h]: [f64; 4], pic: &Picture) { + self.begin(cv, IDENTITY); + let (sx, sy) = (w / f64::from(pic.width), h / f64::from(pic.height)); + let m = mul([sx, 0.0, 0.0, sy, x, y], cv.device_affine()); + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let pt = |a: f32, b: f32| apply(m, (f64::from(a), f64::from(b))); + let ops = |segs: &[Seg]| -> Vec { + segs.iter() + .map(|s| match *s { + Seg::Move(a, b) => Op::M(pt(a, b)), + Seg::Line(a, b) => Op::L(pt(a, b)), + Seg::Cubic(a, b, c, d, e, f) => Op::C(pt(a, b), pt(c, d), pt(e, f)), + Seg::Close => Op::Z, + }) + .collect() + }; + // The nested picture's own clip regions are not carried over: each item takes the + // canvas's current clip. + for item in &pic.items { + if cv.full() { + break; + } + let raw = match item { + Item::Path { segs, fill, stroke, even_odd, .. } => Raw::Path { + ops: ops(segs), + fill: *fill, + stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), + even_odd: *even_odd, + region: None, + }, + Item::Pattern { segs, even_odd, tile, .. } => Raw::Pattern { + ops: ops(segs), + even_odd: *even_odd, + tile: Arc::new(RawTile { + w: tile.width as usize, + h: tile.height as usize, + px: tile.rgba.clone(), + cell: f64::from(tile.cell[0]) * scale, + }), + region: None, + }, + Item::Bitmap { rect, width, height, rgba, .. } => { + let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); + let mut img = Rgba { w: *width as usize, h: *height as usize, bottom_up: false, px: rgba.clone() }; + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img, region: None } + } + Item::Text(t) => { + let mut t = t.clone(); + t.transform = mul(t.transform, m); + if let Some([x0, y0, x1, y1]) = t.clip { + let (p, q) = (pt(x0, y0), pt(x1, y1)); + t.clip = Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]); + } + Raw::Text { text: t, region: None } + } + }; + cv.push_device(raw); + } + self.end(cv); + } + + fn text(&self, cv: &mut Canvas, at: P, text: String, dx: Vec, style: TextStyle, pre: Xform) { + self.begin(cv, pre); + cv.set_text_style(style.color, style.font, style.align); + let (opts, rect) = match style.clip { + Some(c) => (ETO_CLIPPED, Some(c)), + None => (0, None), + }; + cv.text(at, text, dx, opts, rect); + self.end(cv); + } +} + +fn apply(m: Xform, p: P) -> P { + (p.0 * m[0] + p.1 * m[2] + m[4], p.0 * m[1] + p.1 * m[3] + m[5]) +} + +fn invert(m: Xform) -> Option { + let det = m[0] * m[3] - m[1] * m[2]; + if !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let (a, b, c, d) = (m[3] / det, -m[1] / det, -m[2] / det, m[0] / det); + Some([a, b, c, d, -(m[4] * a + m[5] * c), -(m[4] * b + m[5] * d)]) +} + +/// ARGB (0xAARRGGBB) to RGBA. +fn argb(v: u32) -> [u8; 4] { + [(v >> 16) as u8, (v >> 8) as u8, v as u8, (v >> 24) as u8] +} + +fn floats(r: Bytes, off: usize) -> Option<[f64; N]> { + let mut v = [0.0; N]; + for (i, x) in v.iter_mut().enumerate() { + *x = f64::from(r.f32(off + i * 4)?); + } + v.iter().all(|x| x.is_finite()).then_some(v) +} + +fn matrix(r: Bytes, off: usize) -> Option { + floats::<6>(r, off) +} + +fn parse_object(kind: u16, r: Bytes) -> Option { + match kind { + 1 => brush(r, 0).map(Obj::Brush), + 2 => pen(r), + 3 => path(r).map(Obj::Path), + 4 => region(r), + 5 => image(r).map(|i| Obj::Image(Arc::new(i))), + 6 => font(r), + 7 => Some(Obj::Format(Format { flags: r.u32(4)?, align: r.u32(12)?, line_align: r.u32(16)? })), + _ => None, + } +} + +/// A brush object at `off`: version, type, then the type's data. +fn brush(r: Bytes, off: usize) -> Option { + let d = r.tail(off + 8)?; + match r.u32(off + 4)? { + 0 => Some(Fill::Solid(argb(d.u32(0)?))), + 1 => Some(Fill::Hatch { style: d.u32(0)?, fg: argb(d.u32(4)?), bg: argb(d.u32(8)?) }), + 2 => { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let (xf, off) = if bflags & 0x2 != 0 { (matrix(d, 8)?, 32) } else { (IDENTITY, 8) }; + let ImageData::Bitmap(img) = image(d.tail(off)?)? else { return None }; + Some(Fill::Texture { img: Arc::new(img), wrap, xf }) + } + 3 => path_gradient(d), + 4 => linear_gradient(d), + _ => None, + } +} + +fn linear_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let rect = floats::<4>(d, 8)?; + let (c0, c1) = (argb(d.u32(24)?), argb(d.u32(28)?)); + let mut off = 40; + let mut xf = IDENTITY; + if bflags & 0x2 != 0 { + xf = matrix(d, off)?; + off += 24; + } + let mut stops = vec![(0.0, c0), (1.0, c1)]; + if bflags & 0x4 != 0 { + // Preset colours: positions, then one colour each. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let cols: Vec<[u8; 4]> = (0..n).map(|i| d.u32(off + 4 + n * 4 + i * 4).map(argb)).collect::>()?; + stops = pos.into_iter().zip(cols).collect(); + } else if bflags & 0x8 != 0 { + // Blend factors: positions, then how far from the start colour to the end colour. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let fac: Vec = (0..n).map(|i| d.f32(off + 4 + n * 4 + i * 4).map(f64::from)).collect::>()?; + stops = pos.into_iter().zip(fac).map(|(p, k)| (p, lerp(c0, c1, k.clamp(0.0, 1.0)))).collect(); + } + stops.retain(|s| s.0.is_finite()); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(Fill::Linear { rect, stops, wrap, xf }) +} + +/// A path gradient: from the centre colour to the (first) surrounding colour at the boundary, +/// taken as the ellipse in the boundary's bounding rectangle. +fn path_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let center_c = argb(d.u32(8)?); + let [cx, cy] = floats::<2>(d, 12)?; + let nc = (d.u32(20)? as usize).min(MAX_STOPS); + let surround = if nc > 0 { argb(d.u32(24)?) } else { center_c }; + let mut off = 24 + nc * 4; + let bbox = if bflags & 0x1 != 0 { + let size = d.u32(off)? as usize; + let b = ops_bbox(&path(d.tail(off + 4)?)?)?; + off = off.checked_add(4 + size)?; + b + } else { + let n = d.u32(off)? as usize; + let pts = points(d, off + 4, n, false)?; + off = off.checked_add(4 + 8 * n.min(MAX_POINTS))?; + ops_bbox(&poly_ops(&pts, true))? + }; + let xf = if bflags & 0x2 != 0 { matrix(d, off)? } else { IDENTITY }; + let radius = ((bbox[2] - bbox[0]) / 2.0, (bbox[3] - bbox[1]) / 2.0); + Some(Fill::Radial { center: (cx, cy), radius, stops: vec![(0.0, center_c), (1.0, surround)], xf }) +} + +/// A pen object: version, type, then pen data (flags, unit, width, optional fields), then a brush. +fn pen(r: Bytes) -> Option { + let pflags = r.u32(8)?; + let width = f64::from(r.f32(16)?); + let mut off = 20usize; + for (bit, len) in [(0x1, 24), (0x2, 4), (0x4, 4), (0x8, 4), (0x10, 4), (0x20, 4), (0x40, 4), (0x80, 4)] { + if pflags & bit != 0 { + off += len; + } + } + for bit in [0x100, 0x200, 0x400] { + if pflags & bit != 0 { + off += if bit == 0x200 { 4 } else { 4 + 4 * (r.u32(off)? as usize).min(MAX_POINTS) }; + } + } + for bit in [0x800, 0x1000] { + if pflags & bit != 0 { + off += 4 + (r.u32(off)? as usize).min(1 << 20); + } + } + let color = brush(r, off)?.color(); + Some(Obj::Pen { color, width: if width.is_finite() { width.clamp(0.0, 1.0e5) } else { 0.0 } }) +} + +/// A path object: points, then one type byte per point (start, line, Bézier; 0x80 closes). +fn path(r: Bytes) -> Option> { + let n = (r.u32(4)? as usize).min(MAX_POINTS); + let pflags = r.u32(8)?; + if pflags & 0x1800 != 0 { + // Relative (0x0800) and run-length encoded (0x1000) points: not read yet. + return None; + } + let compressed = pflags & 0x4000 != 0; + let pts = points(r, 12, n, compressed)?; + let toff = 12 + n * if compressed { 4 } else { 8 }; + let types = r.slice(toff, n)?; + let mut ops = Vec::with_capacity(n + 4); + let mut i = 0; + while i < n { + let (t, p) = (types[i], pts[i]); + match t & 0x07 { + 0 => ops.push(Op::M(p)), + 3 if i + 2 < n => { + ops.push(Op::C(p, pts[i + 1], pts[i + 2])); + if types[i + 2] & 0x80 != 0 { + ops.push(Op::Z); + } + i += 3; + continue; + } + _ => ops.push(Op::L(p)), + } + if t & 0x80 != 0 { + ops.push(Op::Z); + } + i += 1; + } + Some(ops) +} + +/// A region object as a convex area in world units (non-convex parts become their bounding +/// rectangle). +fn region(r: Bytes) -> Option { + region_node(r, 8, 0).map(|(a, _)| Obj::Region(a)) +} + +/// One region node at `off`: its area and its length in bytes. +fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> { + if depth > MAX_REGION_DEPTH { + return None; + } + match r.u32(off)? { + 0x1000_0000 => { + let [x, y, w, h] = floats::<4>(r, off + 4)?; + Some((Some(rect_poly([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), 20)) + } + 0x1000_0001 => { + let size = r.u32(off + 4)? as usize; + let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default(); + Some((Some(area), 8usize.checked_add(size)?)) + } + 0x1000_0002 => Some((Some(Vec::new()), 4)), + 0x1000_0003 => Some((None, 4)), + mode @ 0..=5 => { + let (a, la) = region_node(r, off + 4, depth + 1)?; + let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; + let out = match mode { + 0 => b, + 1 => area_intersect(a, b), + 2 => area_union(a, b), + // XOR, exclude, complement: the left area bounds the result closely enough. + _ => a, + }; + Some((out, (4 + la).checked_add(lb)?)) + } + _ => None, + } +} + +/// The area a path encloses: its outline when that is one convex figure, else the rectangle +/// around it. +fn area_of_ops(ops: &[Op]) -> Vec

{ + let subs = flatten(ops); + if let [(pts, _)] = subs.as_slice() + && let Some(c) = convex(pts) + { + return c; + } + ops_bbox(ops).map(rect_poly).unwrap_or_default() +} + +fn area_intersect(a: Area, b: Area) -> Area { + match (a, b) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()), + _ => Some(Vec::new()), + } +} + +fn area_union(a: Area, b: Area) -> Area { + match (a, b) { + (Some(a), Some(b)) => { + if a.len() < 3 { + return Some(b); + } + if b.len() < 3 { + return Some(a); + } + let all: Vec

= a.into_iter().chain(b).collect(); + Some(pts_bbox(&all).map(rect_poly).unwrap_or_default()) + } + _ => None, + } +} + +/// An area through an affine map (which keeps it convex; the orientation is put right again). +fn map_area(a: Area, m: Xform) -> Area { + a.map(|pts| { + let mapped: Vec

= pts.iter().map(|p| apply(m, *p)).collect(); + if mapped.len() < 3 { Vec::new() } else { convex(&mapped).unwrap_or_default() } + }) +} + +/// An elliptical arc in `rect` from `start` sweeping `sweep` degrees (clockwise on screen), as +/// cubics; a pie adds the lines to and from the centre. +fn arc_ops([l, t, r, b]: [f64; 4], start: f64, sweep: f64, pie: bool) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let sweep = sweep.clamp(-360.0, 360.0); + // GDI+ angles are along rays from the centre; turn them into the ellipse's parameter. + let param = |deg: f64| { + let a = deg.to_radians(); + (rx * a.sin()).atan2(ry * a.cos()) + }; + let t0 = param(start); + let mut dt = if sweep.abs() >= 360.0 { sweep.signum() * std::f64::consts::TAU } else { param(start + sweep) - t0 }; + if sweep.abs() < 360.0 { + while sweep > 0.0 && dt < 0.0 { + dt += std::f64::consts::TAU; + } + while sweep < 0.0 && dt > 0.0 { + dt -= std::f64::consts::TAU; + } + } + let at = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(8); + if pie { + ops.push(Op::M((cx, cy))); + ops.push(Op::L(at(t0))); + } else { + ops.push(Op::M(at(t0))); + } + let n = ((dt.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 4); + let step = dt / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + for i in 0..n { + let (a, e) = (t0 + step * i as f64, t0 + step * (i + 1) as f64); + let (p0, p3) = (at(a), at(e)); + let c1 = (p0.0 - k * rx * a.sin(), p0.1 + k * ry * a.cos()); + let c2 = (p3.0 + k * rx * e.sin(), p3.1 - k * ry * e.cos()); + ops.push(Op::C(c1, c2, p3)); + } + if pie { + ops.push(Op::Z); + } + ops +} + +/// A font object: em size, unit, style flags, then the family name in UTF-16. +fn font(r: Bytes) -> Option { + let em = f64::from(r.f32(4)?); + let n = (r.u32(20)? as usize).min(64); + let units: Vec = (0..n).map(|i| r.u16(24 + i * 2)).collect::>()?; + let family = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string(); + em.is_finite().then_some(Obj::Font(FontObj { em, unit: r.u32(8)?, style: r.u32(12)?, family })) +} + +/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file, or a nested +/// metafile. +fn image(r: Bytes) -> Option { + match r.u32(4)? { + 1 => bitmap(r).map(ImageData::Bitmap), + 2 => metafile(r).map(ImageData::Meta), + _ => None, + } +} + +/// A metafile image: type, size, then a WMF (placeable or not) or EMF file, parsed as a picture. +fn metafile(r: Bytes) -> Option { + let size = r.u32(12)? as usize; + let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?; + let depth = NESTING.with(|n| n.get()); + if depth >= MAX_NESTING { + return None; + } + NESTING.with(|n| n.set(depth + 1)); + let pic = crate::parse(data).ok(); + NESTING.with(|n| n.set(depth)); + pic.filter(|p| p.width > 0.0 && p.height > 0.0) +} + +fn bitmap(r: Bytes) -> Option { + let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); + let format = r.u32(20)?; + let data = r.tail(28)?; + if r.u32(24)? == 1 { + return decode_compressed(data.0); + } + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + let bpp = ((format >> 8) & 0xFF) as usize; + let (pal, pixels) = if format & 0x0001_0000 != 0 { + // Indexed formats carry a palette first: flags, count, ARGB entries. + let n = (data.u32(4)? as usize).min(256); + let pal: Vec<[u8; 4]> = (0..n).map(|i| data.u32(8 + i * 4).map(argb)).collect::>()?; + (pal, data.tail(8 + n * 4)?) + } else { + (Vec::new(), data) + }; + let row = (w.checked_mul(bpp)?).div_ceil(8); + let stride_abs = (stride.unsigned_abs() as usize).max(row); + let mut px = Vec::with_capacity(w * h * 4); + for y in 0..h { + let line = pixels.slice(y.checked_mul(stride_abs)?, row)?; + for x in 0..w { + let c = match (format, bpp) { + (_, 1 | 4 | 8) if !pal.is_empty() => { + let bit = x * bpp; + let byte = *line.get(bit / 8)?; + let i = (byte >> (8 - bpp - bit % 8)) & ((1u16 << bpp) - 1) as u8; + pal.get(usize::from(i)).copied().unwrap_or([0, 0, 0, 255]) + } + (_, 24) => { + let p = line.get(x * 3..x * 3 + 3)?; + [p[2], p[1], p[0], 255] + } + (0x0002_2009, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], 255] + } + (0x000E_200B, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + let a = u32::from(p[3]); + let un = |v: u8| (u32::from(v) * 255 + a / 2).checked_div(a).map_or(v, |c| c.min(255) as u8); + [un(p[2]), un(p[1]), un(p[0]), p[3]] + } + (_, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], p[3]] + } + (0x0002_1006, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16, bits: u32| ((u32::from(v) * 255) / ((1 << bits) - 1)) as u8; + [s(v >> 11, 5), s((v >> 5) & 0x3F, 6), s(v & 0x1F, 5), 255] + } + (_, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16| ((u32::from(v & 0x1F) * 255) / 31) as u8; + [s(v >> 10), s(v >> 5), s(v), 255] + } + _ => return None, + }; + px.extend_from_slice(&c); + } + } + // A negative stride stores the rows bottom-up. + Some(top_down(Rgba { w, h, bottom_up: stride < 0, px })) +} + +fn top_down(mut img: Rgba) -> Rgba { + if img.bottom_up { + crate::dib::flip(&mut img, false, true); + img.bottom_up = false; + } + img +} + +fn decode_compressed(bytes: &[u8]) -> Option { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(1 << 15); + limits.max_image_height = Some(1 << 15); + limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64); + reader.limits(limits); + let img = reader.decode().ok()?.to_rgba8(); + let (w, h) = (img.width() as usize, img.height() as usize); + if w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + Some(Rgba { w, h, bottom_up: false, px: img.into_raw() }) +} + +/// `n` points at `off`: 16-bit integers when compressed, else 32-bit floats. +fn points(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { + let n = n.min(MAX_POINTS); + let mut v = Vec::with_capacity(n); + for i in 0..n { + v.push(if compressed { + (f64::from(r.i16(off + i * 4)?), f64::from(r.i16(off + i * 4 + 2)?)) + } else { + let (x, y) = (f64::from(r.f32(off + i * 8)?), f64::from(r.f32(off + i * 8 + 4)?)); + if !(x.is_finite() && y.is_finite()) { + return None; + } + (x, y) + }); + } + Some(v) +} + +/// `n` rectangles (x, y, w, h) at `off`, as `[l, t, r, b]`. +fn rects(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { + let pts = points(r, off, n.min(MAX_POINTS / 2).checked_mul(2)?, compressed)?; + Some(pts.as_chunks::<2>().0.iter().map(|c| [c[0].0, c[0].1, c[0].0 + c[1].0, c[0].1 + c[1].1]).collect()) +} + +fn rect_ops([l, t, r, b]: [f64; 4]) -> Vec { + crate::geom::rect(l, t, r, b) +} + +fn finite(v: f32) -> Option { + v.is_finite().then_some(f64::from(v)) +} + +/// A GDI+ cardinal spline through `pts` as cubic Béziers: each segment's control points lie a +/// third of `tension` (GDI+'s default 0.5) along the neighbouring chords. `closed` joins the +/// last point back to the first; an open curve draws `segments` segments from point `offset`. +fn curve_ops(pts: &[P], tension: f64, closed: bool, offset: usize, segments: usize) -> Vec { + let n = pts.len(); + if n < 2 { + return Vec::new(); + } + let k = tension.clamp(0.0, 10.0) / 3.0; + let at = |i: isize| -> P { + let i = if closed { i.rem_euclid(n as isize) as usize } else { i.clamp(0, n as isize - 1) as usize }; + pts.get(i).copied().unwrap_or((0.0, 0.0)) + }; + let (first, count) = if closed { (0, n) } else { (offset.min(n - 1), segments.min(n - 1 - offset.min(n - 1))) }; + let mut ops = vec![Op::M(at(first as isize))]; + for s in first..first + count { + let i = s as isize; + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = (p1.0 + (p2.0 - p0.0) * k, p1.1 + (p2.1 - p0.1) * k); + let c2 = (p2.0 - (p3.0 - p1.0) * k, p2.1 - (p3.1 - p1.1) * k); + ops.push(Op::C(c1, c2, p2)); + } + if closed { + ops.push(Op::Z); + } + ops +} + +fn poly_ops(pts: &[P], close: bool) -> Vec { + let mut ops: Vec = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect(); + if close { + ops.push(Op::Z); + } + ops +} + +/// An ellipse in its bounding box, as four cubic arcs. +fn ellipse_ops([l, t, r, b]: [f64; 4]) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let k = 0.552_284_75; + let (ox, oy) = (rx * k, ry * k); + vec![ + Op::M((cx + rx, cy)), + Op::C((cx + rx, cy + oy), (cx + ox, cy + ry), (cx, cy + ry)), + Op::C((cx - ox, cy + ry), (cx - rx, cy + oy), (cx - rx, cy)), + Op::C((cx - rx, cy - oy), (cx - ox, cy - ry), (cx, cy - ry)), + Op::C((cx + ox, cy - ry), (cx + rx, cy - oy), (cx + rx, cy)), + Op::Z, + ] +} + +/// Core fonts whose first glyphs follow the standard Macintosh order (glyph 3 is the space, +/// glyphs 3..=97 are U+0020..=U+007E). +const MAC_ORDER_FONTS: &[&str] = + &["arial", "arial black", "times new roman", "courier new", "verdana", "georgia", "tahoma", "trebuchet ms", "comic sans ms", "impact"]; + +/// Glyph indices of a known font back to ASCII; None when the font is unknown or a glyph is +/// outside the ASCII range. +fn glyphs_to_chars(family: &str, glyphs: &[u16]) -> Option> { + let family = family.trim().to_ascii_lowercase(); + if !MAC_ORDER_FONTS.contains(&family.as_str()) { + return None; + } + glyphs.iter().map(|g| (3..=97).contains(g).then_some(g + 29)).collect() +} + +/// A rough advance for a character in ems: the metafile crate has no fonts, so line breaks for +/// wrapped strings use typical proportional widths. +fn char_em(u: u16) -> f64 { + match char::from_u32(u32::from(u)).unwrap_or('x') { + ' ' | 'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '!' | '|' | 'I' | 'f' | 't' | 'r' => 0.3, + 'm' | 'w' | 'M' | 'W' => 0.85, + c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62, + c if (c as u32) >= 0x2E80 => 1.0, + _ => 0.52, + } +} + +/// Greedy word wrap of one line to `width` world units at em size `em`; a word longer than the +/// line breaks between characters. +fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec> { + if em <= 0.0 || !width.is_finite() { + return vec![line.to_vec()]; + } + let fits = |l: &[u16]| l.iter().map(|u| char_em(*u) * em).sum::() <= width; + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for word in line.split_inclusive(|u| *u == 0x20) { + let mut cand = cur.clone(); + cand.extend_from_slice(word); + let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + if fits(&cand[..trimmed(&cand)]) || cur.is_empty() && fits(&word[..trimmed(word)]) { + cur = cand; + continue; + } + if !cur.is_empty() { + let n = trimmed(&cur); + cur.truncate(n); + out.push(std::mem::take(&mut cur)); + } + // A word wider than the line on its own: break it between characters. + for &u in word { + cur.push(u); + if !fits(&cur[..trimmed(&cur)]) && cur.len() > 1 { + cur.pop(); + out.push(std::mem::take(&mut cur)); + cur.push(u); + } + } + } + let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + cur.truncate(n); + out.push(cur); + out +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index d7d98501..8e10cdf3 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -9,14 +9,19 @@ //! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], //! never a panic. //! -//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently. +//! Records follow MS-WMF and MS-EMF. Rectangle clip regions (`INTERSECTCLIPRECT`, +//! `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit what later records draw. Records outside the +//! supported set are skipped silently. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] mod bytes; mod canvas; +mod clip; +mod cliprect; mod dib; mod emf; +mod emfplus; mod geom; mod place; mod text; @@ -66,12 +71,27 @@ pub enum Item { /// Colour and width in picture units. stroke: Option<([u8; 4], f32)>, even_odd: bool, + /// Clip region in picture units, each rectangle `[x0, y0, x1, y1]`. None is the default clip. + region: Option>, }, /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. - Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, + Bitmap { + rect: [f32; 4], + width: u32, + height: u32, + rgba: Vec, + /// Clip region in picture units. None is the default clip. + region: Option>, + }, /// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same /// brush share its tile. - Pattern { segs: Vec, even_odd: bool, tile: Arc }, + Pattern { + segs: Vec, + even_odd: bool, + tile: Arc, + /// Clip region in picture units. None is the default clip. + region: Option>, + }, /// A text string. Text(Box), } @@ -153,20 +173,20 @@ fn build(raw: Vec, frame: Option) -> Picture { // Tiles shared by several fills stay shared. let mut tiles: HashMap<*const canvas::RawTile, (Arc, Arc)> = HashMap::new(); let items = raw.into_iter().filter_map(|r| match r { - Raw::Path { ops, fill, stroke, even_odd } => { + Raw::Path { ops, fill, stroke, even_odd, region } => { let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; if segs.len() < 2 { return None; } let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?))); - Some(Item::Path { segs, fill, stroke, even_odd }) + Some(Item::Path { segs, fill, stroke, even_odd, region: map_region(region, &tf)? }) } - Raw::Bitmap { rect: r, img } => { + Raw::Bitmap { rect: r, img, region } => { let (x, y) = tf((r[0], r[1])); let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; - Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) + Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px, region: map_region(region, &tf)? }) } - Raw::Pattern { ops, even_odd, tile } => { + Raw::Pattern { ops, even_odd, tile, region } => { let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; if segs.len() < 2 { return None; @@ -188,27 +208,38 @@ fn build(raw: Vec, frame: Option) -> Picture { t } }; - Some(Item::Pattern { segs, even_odd, tile }) + Some(Item::Pattern { segs, even_odd, tile, region: map_region(region, &tf)? }) } - Raw::Text(mut t) => { + Raw::Text { mut text, region } => { // Device to picture: scale by (sx, sy) after moving the frame's corner to the origin. - let [a, b, c, d, e, g] = t.transform; + let [a, b, c, d, e, g] = text.transform; let (e, g) = tf((e, g)); - t.transform = [a * sx, b * sy, c * sx, d * sy, e, g]; - if !t.transform.iter().all(|v| v.is_finite()) { + text.transform = [a * sx, b * sy, c * sx, d * sy, e, g]; + if !text.transform.iter().all(|v| v.is_finite()) { return None; } - if let Some([x0, y0, x1, y1]) = t.clip { + if let Some([x0, y0, x1, y1]) = text.clip { let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1)))); - t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); + text.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); } - Some(Item::Text(t)) + text.region = map_region(region, &tf)?; + Some(Item::Text(text)) } }); let items = items.collect(); Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } } +fn map_region(region: Option>, tf: &impl Fn(P) -> P) -> Option>> { + let Some(rects) = region else { return Some(None) }; + let mut out = Vec::with_capacity(rects.len()); + for [x0, y0, x1, y1] in rects { + let (p, q) = (tf((x0, y0)), tf((x1, y1))); + out.push([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); + } + Some(Some(out)) +} + fn f(v: f64) -> Option { let v = v as f32; v.is_finite().then_some(v) @@ -268,7 +299,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { grow((rect[0], rect[1])); grow((rect[0] + rect[2], rect[1] + rect[3])); } - Raw::Text(t) => grow((t.transform[4], t.transform[5])), + Raw::Text { text: t, .. } => grow((t.transform[4], t.transform[5])), } } b diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 28e0b6ae..d9d3bdf8 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -16,15 +16,15 @@ const MIN_BITMAP_SIDE: f64 = 1e-6; pub enum PlacedItem { /// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that /// the renderer draws at its minimum width. - Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, + Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, region: Option }, /// `Picture::items[index]`, a bitmap, stretched into `rect`. - Bitmap { index: usize, rect: Rect }, + Bitmap { index: usize, rect: Rect, region: Option }, /// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile /// repeated; `tile` maps tile pixels to target units. - Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine }, + Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine, region: Option }, /// `Picture::items[index]`, a text string: `transform` maps its text space to target units; - /// `clip` (target units) bounds what it draws. - Text { index: usize, transform: Affine, clip: Option }, + /// `clip` (target units) bounds what it draws. `region` is the device-context clip. + Text { index: usize, transform: Affine, clip: Option, region: Option }, } impl Picture { @@ -55,38 +55,42 @@ impl Picture { let mut out = Vec::with_capacity(self.items.len()); for (index, item) in self.items.iter().enumerate() { match item { - Item::Path { segs, fill, stroke, even_odd } => { + Item::Path { segs, fill, stroke, even_odd, region } => { let path = tf * bez(segs); + let Some(region) = placed_region(&tf, region) else { continue }; if in_range(path.bounding_box()) { let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k)); - out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd }); + out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region }); } } - Item::Pattern { segs, even_odd, tile: t } => { + Item::Pattern { segs, even_odd, tile: t, region } => { let path = tf * bez(segs); + let Some(region) = placed_region(&tf, region) else { continue }; let tile = tf * Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1]))) * Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1])); let det = tile.determinant(); if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 { - out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile }); + out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region }); } } Item::Text(t) => { let transform = tf * Affine::new(t.transform); + let Some(region) = placed_region(&tf, &t.region) else { continue }; let clip = t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)))); let c = transform.as_coeffs(); let origin = Rect::new(c[4], c[5], c[4], c[5]); if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) { - out.push(PlacedItem::Text { index, transform, clip }); + out.push(PlacedItem::Text { index, transform, clip, region }); } } - Item::Bitmap { rect: [x, y, w, h], .. } => { + Item::Bitmap { rect: [x, y, w, h], region, .. } => { let (x0, y0) = (f64::from(*x), f64::from(*y)); let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); + let Some(region) = placed_region(&tf, region) else { continue }; if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE { - out.push(PlacedItem::Bitmap { index, rect }); + out.push(PlacedItem::Bitmap { index, rect, region }); } } } @@ -107,6 +111,20 @@ impl Item { } } +/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the +/// region does not meet the page, so the item draws nothing. +fn placed_region(tf: &Affine, region: &Option>) -> Option> { + let Some(rects) = region else { return Some(None) }; + let mut path = BezPath::new(); + for [x0, y0, x1, y1] in rects { + let r = tf.transform_rect_bbox(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1))); + if in_range(r) && r.width() > 1e-9 && r.height() > 1e-9 { + path.extend(r.to_path(0.1)); + } + } + (!path.is_empty()).then_some(Some(path)) +} + /// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point. fn bez(segs: &[Seg]) -> BezPath { let mut p = BezPath::new(); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 271cc83b..fb7dfe36 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -119,7 +119,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255 fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { match item { - Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), + Item::Path { segs, fill, stroke, even_odd, .. } => (segs, *fill, *stroke, *even_odd), other => panic!("expected a path: {other:?}"), } } @@ -179,7 +179,7 @@ fn wmf_stretchdib_flips_bottom_up_rows() { let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); assert_eq!((p.width, p.height), (20.0, 10.0)); - let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); assert_eq!((*width, *height), (2, 2)); assert_eq!(rgba.as_slice(), DIB24_RGBA); @@ -236,7 +236,7 @@ fn emf_stretchdibits_flips_and_fits_frame() { let p = parse(&file).unwrap(); let (w, h) = (2500.0 * 72.0 / 2540.0, 1250.0 * 72.0 / 2540.0); assert!(close(p.width, w as f32) && close(p.height, h as f32)); - let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; assert!(rect.iter().zip([0.0, 0.0, w, h]).all(|(a, b)| close(*a, b as f32))); assert_eq!((*width, *height), (2, 2)); assert_eq!(rgba.as_slice(), DIB24_RGBA); @@ -428,7 +428,7 @@ fn wmf_stretchdib_clipped_source_keeps_its_place() { // destination and the picture keeps the original 20-unit width scale. let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(-1), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([0, 0, 20, 10]))).unwrap(); - let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; // Placeable units are points: 20 units at 1440 per inch are 1 point, so the right column is 0.5 pt wide. assert_eq!(rect, &[0.5, 0.0, 0.5, 0.5]); assert_eq!((*width, *height), (1, 2)); @@ -723,7 +723,7 @@ fn wmf_dibbitblt_copies_the_dib_at_its_destination() { params.extend(dib24_bottom_up()); let recs = [wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])), wrec(0x0940, ¶ms)]; let p = parse(&wmf(&recs, None)).unwrap(); - let Item::Bitmap { rect, width, height, rgba } = &p.items[1] else { panic!("expected a bitmap") }; + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; assert_eq!(rect, &[5.0, 6.0, 2.0, 2.0]); assert_eq!((*width, *height), (2, 2)); assert_eq!(rgba.as_slice(), DIB24_RGBA); @@ -881,8 +881,14 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { width: 100.0, height: 50.0, items: vec![ - Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], fill: None, stroke: Some(([0, 0, 0, 255], 4.0)), even_odd: true }, - Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true }, + Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], + fill: None, + stroke: Some(([0, 0, 0, 255], 4.0)), + even_odd: true, + region: None, + }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true, region: None }, ], }; let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); @@ -893,7 +899,7 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { - let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; + let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None }; let pic = crate::Picture { width: 100.0, height: 50.0, @@ -907,21 +913,21 @@ fn place_keeps_only_finite_bounded_bitmaps_with_area() { }; let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); assert_eq!(placed.len(), 1); - let crate::PlacedItem::Bitmap { index, rect } = &placed[0] else { panic!("expected a bitmap") }; + let crate::PlacedItem::Bitmap { index, rect, .. } = &placed[0] else { panic!("expected a bitmap") }; assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0))); } #[test] fn take_pixels_returns_only_drawable_bitmaps() { - let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] }; + let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8], region: None }; assert_eq!(ok.take_pixels(), Some((2, 1, vec![7; 8]))); assert!(matches!(&ok, Item::Bitmap { rgba, .. } if rgba.is_empty()), "the pixels move out"); - let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8] }; + let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8], region: None }; assert_eq!(short.take_pixels(), None); let side = crate::MAX_BITMAP_SIDE + 1; - let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4] }; + let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4], region: None }; assert_eq!(wide.take_pixels(), None); - let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false }; + let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false, region: None }; assert_eq!(path.take_pixels(), None); let pic = |width: f32, height: f32| crate::Picture { width, height, items: Vec::new() }; assert!(pic(1.0, 2.0).has_size()); @@ -1518,3 +1524,501 @@ fn pattern_brush_pixels_count_against_the_byte_budget() { let p = wmf200(&recs); assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len()); } + +fn item_region(item: &Item) -> Option<&[[f32; 4]]> { + match item { + Item::Path { region, .. } | Item::Bitmap { region, .. } | Item::Pattern { region, .. } => region.as_deref(), + Item::Text(t) => t.region.as_deref(), + } +} + +#[test] +fn emf_intersect_clip_rect_limits_later_records_only() { + let full = cat(vec![e32(0), e32(0), e32(80), e32(40)]); + let clip = cat(vec![e32(10), e32(5), e32(40), e32(30)]); + let p = parse(&emf(&[erec(43, &full), erec(30, &clip), erec(43, &full)], [0, 0, 80, 40], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 2); + assert_eq!(item_region(&p.items[0]), None); + assert_eq!(item_region(&p.items[1]), Some(&[[10.0, 5.0, 40.0, 30.0]][..])); +} + +#[test] +fn emf_clip_follows_the_world_transform_and_save_restores_it() { + let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let rect = cat(vec![e32(0), e32(0), e32(10), e32(10)]); + let clip = cat(vec![e32(0), e32(0), e32(5), e32(5)]); + let p = parse(&emf(&[erec(35, &xf), erec(30, &clip), erec(43, &rect)], [0, 0, 20, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 10.0]][..])); + + let full = cat(vec![e32(0), e32(0), e32(40), e32(20)]); + let p = + parse(&emf(&[erec(43, &full), erec(33, &[]), erec(30, &clip), erec(43, &full), erec(34, &e32(-1)), erec(43, &full)], [0, 0, 40, 20], [0; 4])) + .unwrap(); + assert_eq!(p.items.len(), 3); + assert_eq!(item_region(&p.items[0]), None); + assert_eq!(item_region(&p.items[1]), Some(&[[0.0, 0.0, 5.0, 5.0]][..])); + assert_eq!(item_region(&p.items[2]), None); +} + +#[test] +fn emf_ext_select_clip_rgn_copies_and_intersects_rectangles() { + let mut data = cat(vec![h32(32), h32(1), h32(2), h32(32), e32(0), e32(0), e32(50), e32(20)]); + data.extend(cat(vec![e32(0), e32(0), e32(10), e32(20), e32(30), e32(0), e32(50), e32(20)])); + let payload = cat(vec![h32(data.len() as u32), h32(5), data]); + let rect = cat(vec![e32(0), e32(0), e32(50), e32(20)]); + let p = parse(&emf(&[erec(75, &payload), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 20.0], [30.0, 0.0, 50.0, 20.0]][..])); + + let and = cat(vec![h32(0), h32(1)]); + // A zero-length region selects the default clip, so the next rectangle is not clipped. + let p = parse(&emf(&[erec(75, &payload), erec(75, &and), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), None); + + let overlap = cat(vec![e32(40), e32(0), e32(80), e32(20)]); + let p = parse(&emf(&[erec(75, &payload), erec(30, &overlap), erec(43, &rect)], [0, 0, 80, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[40.0, 0.0, 50.0, 20.0]][..])); +} + +#[test] +fn emf_exclude_clip_rect_cuts_the_surface() { + let hole = cat(vec![e32(20), e32(0), e32(40), e32(40)]); + let rect = cat(vec![e32(0), e32(0), e32(80), e32(40)]); + let p = parse(&emf(&[erec(29, &hole), erec(43, &rect)], [0, 0, 80, 40], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 80.0, 40.0]][..])); +} + +#[test] +fn an_empty_clip_drops_later_drawing() { + let empty = cat(vec![e32(5), e32(5), e32(5), e32(8)]); + let rect = cat(vec![e32(0), e32(0), e32(20), e32(10)]); + let p = parse(&emf(&[erec(43, &rect), erec(30, &empty), erec(43, &rect)], [0, 0, 20, 10], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn wmf_intersect_clip_rect_limits_later_records() { + let clip = cat(vec![h16(30), h16(40), h16(5), h16(10)]); + let rect = cat(vec![h16(40), h16(80), h16(0), h16(0)]); + let p = parse(&wmf(&[wrec(0x0416, &clip), wrec(0x041B, &rect)], None)).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[10.0, 5.0, 40.0, 30.0]][..])); +} + +// ---- EMF+ ---- + +fn f32s(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// One EMF+ record: type, flags, size, data size, data (padded to 4). +fn prec(typ: u16, flags: u16, data: &[u8]) -> Vec { + let mut d = data.to_vec(); + while !d.len().is_multiple_of(4) { + d.push(0); + } + let mut v = typ.to_le_bytes().to_vec(); + v.extend(flags.to_le_bytes()); + v.extend(((12 + d.len()) as u32).to_le_bytes()); + v.extend((d.len() as u32).to_le_bytes()); + v.extend(d); + v +} + +/// An EMR_COMMENT carrying EMF+ records. +fn plus_comment(recs: &[Vec]) -> Vec { + let body = recs.concat(); + let mut p = ((body.len() + 4) as u32).to_le_bytes().to_vec(); + p.extend(0x2B46_4D45u32.to_le_bytes()); + p.extend(body); + erec(70, &p) +} + +/// EMF+ header: dual (bit 0) or EMF+ only, 96 dpi. +fn plus_header(dual: bool) -> Vec { + prec(0x4001, u16::from(dual), &cat(vec![h32(0xDBC0_1002), h32(0), h32(96), h32(96)])) +} + +fn solid_pen(argb: u32, width: f32) -> Vec { + cat(vec![h32(0xDBC0_1002), h32(0), h32(0), h32(0), f32s(&[width]), h32(0xDBC0_1002), h32(0), h32(argb)]) +} + +#[test] +fn emfplus_only_fill_rects_with_argb_colour() { + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_80FF), h32(1), f32s(&[10.0, 20.0, 30.0, 40.0])])); + let p = parse(&emf(&[plus_comment(&[plus_header(false), fill])], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0x00, 0x80, 0xFF, 0xFF])); + assert_eq!(stroke, None); + // Geometry is placed relative to the drawing's bounding box (no frame given). + assert_eq!((p.width, p.height), (30.0, 40.0)); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert!(segs.contains(&Seg::Line(30.0, 40.0))); +} + +#[test] +fn emfplus_dual_files_draw_only_the_emf_records() { + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_0000), h32(1), f32s(&[0.0, 0.0, 5.0, 5.0])])); + let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); + let p = parse(&emf(&[plus_comment(&[plus_header(true), fill]), gdi], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + assert_eq!(path_segs(&p.items[0]).1, Some([255, 255, 255, 255])); +} + +#[test] +fn emfplus_pen_object_draws_lines_under_a_scale_transform() { + let pen = prec(0x4008, 0x0200 | 3, &solid_pen(0xFF00_FF00, 2.0)); + let scale = prec(0x402E, 0, &f32s(&[2.0, 3.0])); + let lines = prec(0x400D, 3, &cat(vec![h32(2), f32s(&[1.0, 1.0, 4.0, 2.0])])); + let p = parse(&emf(&[plus_comment(&[plus_header(false), pen, scale, lines])], [0; 4], [0; 4])).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + // (1,1)-(4,2) scaled by (2,3) is (2,3)-(8,6), placed relative to its bounding box. + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(6.0, 3.0)]); + assert_eq!(fill, None); + let (c, w) = stroke.unwrap(); + assert_eq!(c, [0, 255, 0, 255]); + assert!(w > 0.0); +} + +#[test] +fn emfplus_path_object_fills_with_brush_object_and_restore_undoes_transform() { + let brush = prec(0x4008, 0x0100 | 1, &cat(vec![h32(0xDBC0_1002), h32(0), h32(0x8012_3456)])); + // Triangle: start, line, line|close. + let path = prec(0x4008, 0x0300 | 2, &cat(vec![h32(0xDBC0_1002), h32(3), h32(0), f32s(&[0.0, 0.0, 10.0, 0.0, 0.0, 10.0]), vec![0, 1, 0x81]])); + let save = prec(0x4025, 0, &h32(7)); + let mv = prec(0x402D, 0, &f32s(&[100.0, 0.0])); + let restore = prec(0x4026, 0, &h32(7)); + let fill = prec(0x4014, 2, &h32(1)); + let p = parse(&emf(&[plus_comment(&[plus_header(false), brush, path, save, mv, restore, fill])], [0; 4], [0; 4])).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0x12, 0x34, 0x56, 0x80])); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(0.0, 10.0), Seg::Close]); +} + +#[test] +fn emfplus_garbage_records_never_panic() { + let mut s = 0x1234_5678u32; + for _ in 0..300 { + let mut recs = vec![plus_header(false)]; + for _ in 0..6 { + s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let typ = 0x4000 + (s >> 16) as u16 % 0x40; + let data: Vec = (0..(s % 64)).map(|i| (s.rotate_left(i)) as u8).collect(); + recs.push(prec(typ, (s >> 8) as u16, &data)); + } + let _ = parse(&emf(&[plus_comment(&recs)], [0; 4], [0; 4])); + } +} + +fn plus_pic(recs: Vec>) -> Picture { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0; 4], [0; 4])).unwrap() +} + +fn u16s(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white. +fn bitmap_2x2() -> Vec { + let px = [0xFFFF_0000u32, 0xFF00_FF00, 0xFF00_00FF, 0xFFFF_FFFF]; + cat(vec![h32(0xDBC0_1002), h32(1), h32(2), h32(2), h32(8), h32(0x0026_200A), h32(0), px.iter().flat_map(|v| v.to_le_bytes()).collect()]) +} + +fn draw_image(id: u16, dst: [f32; 4]) -> Vec { + prec(0x401A, id, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 2.0, 2.0]), f32s(&dst)])) +} + +#[test] +fn emfplus_draw_image_places_the_bitmap() { + let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); + match &p.items[0] { + Item::Bitmap { rect, width, height, rgba, .. } => { + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); + assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); + assert_eq!(&rgba[8..12], &[0, 0, 255, 255]); + } + other => panic!("expected a bitmap: {other:?}"), + } +} + +#[test] +fn emfplus_object_split_across_records_is_joined() { + let obj = bitmap_2x2(); + let (a, b) = obj.split_at(20); + let total = h32(obj.len() as u32); + let first = prec(0x4008, 0x8000 | 0x0500 | 1, &cat(vec![total.clone(), a.to_vec()])); + let second = prec(0x4008, 0x0500 | 1, b); + let p = plus_pic(vec![first, second, draw_image(1, [0.0, 0.0, 4.0, 4.0])]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 2, height: 2, .. })); +} + +#[test] +fn emfplus_compressed_png_image_decodes() { + let mut png = Vec::new(); + let img = image::RgbaImage::from_pixel(3, 1, image::Rgba([1, 2, 3, 255])); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let obj = cat(vec![h32(0xDBC0_1002), h32(1), h32(0), h32(0), h32(0), h32(0), h32(1), png]); + let draw = prec(0x401A, 2, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 3.0, 1.0]), f32s(&[0.0, 0.0, 3.0, 1.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 2, &obj), draw]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 3, height: 1, rgba, .. } if rgba[..4] == [1, 2, 3, 255])); +} + +fn font_obj(family: &str, em: f32, unit: u32, style: u32) -> Vec { + cat(vec![h32(0xDBC0_1002), f32s(&[em]), h32(unit), h32(style), h32(0), h32(family.encode_utf16().count() as u32), u16s(family)]) +} + +#[test] +fn emfplus_draw_string_with_font_and_centred_format() { + let font = prec(0x4008, 0x0600 | 3, &font_obj("Calibri", 12.0, 2, 1)); + // String format: centre alignment, no clip. + let fmt = prec(0x4008, 0x0700 | 4, &cat(vec![h32(0xDBC0_1002), h32(0x4000), h32(0), h32(1), h32(0)])); + let s = prec(0x401C, 0x8000 | 3, &cat(vec![h32(0xFF11_2233), h32(4), h32(2), f32s(&[0.0, 0.0, 100.0, 20.0]), u16s("Hi")])); + let p = plus_pic(vec![font, fmt, prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 20.0])])), s]); + let t = p.items.iter().find_map(|i| if let Item::Text(t) = i { Some(t) } else { None }).expect("a text item"); + assert_eq!(t.text, "Hi"); + assert_eq!(t.font.family, "Calibri"); + assert_eq!(t.font.weight, 700); + assert!(close(t.font.height, -12.0)); + assert_eq!(t.color, [0x11, 0x22, 0x33, 0xFF]); + assert_eq!(t.h_align, HAlign::Center); + assert!(close(t.transform[4] as f32, 50.0)); + assert_eq!(t.background, None); + assert_eq!(t.clip, None); +} + +#[test] +fn emfplus_driver_string_uses_character_codes_and_positions() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let pos = f32s(&[5.0, 30.0, 11.0, 30.0]); + let s = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), h32(0), h32(2), u16s("Ab"), pos])); + let p = plus_pic(vec![prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 50.0, 50.0])])), font, s]); + let Some(Item::Text(t)) = p.items.get(1) else { panic!("expected text: {:?}", p.items) }; + assert_eq!(t.text, "Ab"); + assert_eq!(t.dx, vec![6.0, 6.0]); + assert_eq!(t.v_align, VAlign::Baseline); + assert!(close(t.transform[4] as f32, 5.0) && close(t.transform[5] as f32, 30.0)); +} + +#[test] +fn emfplus_glyph_index_driver_strings_map_back_in_core_fonts_only() { + // Glyph indices (no CmapLookup): 36, 69 are "Ab" in Arial's standard glyph order. + let gi = || prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(2), h16(36), h16(69), f32s(&[0.0, 0.0, 6.0, 0.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)), gi()]); + assert!(matches!(&p.items[0], Item::Text(t) if t.text == "Ab"), "{:?}", p.items); + // An unknown font's glyph indices cannot be mapped: nothing is drawn. + assert!(plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Calibri", 10.0, 0, 0)), gi()]).items.is_empty()); +} + +#[test] +fn emfplus_linear_gradient_fills_with_a_shaded_tile() { + // Red to blue across x = 0..100. + let brush = + cat(vec![h32(0xDBC0_1002), h32(4), h32(0), h32(0), f32s(&[0.0, 0.0, 100.0, 10.0]), h32(0xFFFF_0000), h32(0xFF00_00FF), h32(0), h32(0)]); + let fill = prec(0x400A, 0, &cat(vec![h32(5), h32(1), f32s(&[0.0, 0.0, 100.0, 10.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100 | 5, &brush), fill]); + let Item::Pattern { tile, .. } = &p.items[0] else { panic!("expected a pattern: {:?}", p.items) }; + let px = |x: usize| &tile.rgba[x * 4..x * 4 + 4]; + assert!(px(0)[0] > 240 && px(0)[2] < 15, "left is red: {:?}", px(0)); + let right = (tile.width as usize - 2).min(127); + assert!(px(right)[2] > 230 && px(right)[0] < 25, "right is blue: {:?}", px(right)); +} + +#[test] +fn emfplus_hatch_brush_fills_with_an_8x8_tile() { + let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(4), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); + let fill = prec(0x400A, 0, &cat(vec![h32(0), h32(1), f32s(&[0.0, 0.0, 16.0, 16.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100, &brush), fill]); + assert!(matches!(&p.items[0], Item::Pattern { tile, .. } if tile.width == 8 && tile.height == 8)); +} + +#[test] +fn emfplus_clip_rect_cuts_fills_strokes_and_images() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 50.0, 50.0])); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[25.0, 25.0, 50.0, 50.0])])); + let outside = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[60.0, 60.0, 5.0, 5.0])])); + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_FF00, 1.0)); + let line = prec(0x400D, 1, &cat(vec![h32(2), f32s(&[0.0, 10.0, 100.0, 10.0])])); + let img = prec(0x4008, 0x0500 | 2, &bitmap_2x2()); + let p = plus_pic(vec![clip, fill, outside, pen, line, img, draw_image(2, [40.0, 0.0, 20.0, 20.0])]); + assert_eq!(p.items.len(), 3, "{:?}", p.items); + let (segs, ..) = path_segs(&p.items[0]); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + // Picture coordinates start at the drawing's left edge (0): the square is cut at x = 50. + assert!(xs.iter().all(|x| *x <= 50.0 + 1e-3), "{segs:?}"); + let (segs, ..) = path_segs(&p.items[1]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, 50.0)), "{segs:?}"); + let Item::Bitmap { rect, width, .. } = &p.items[2] else { panic!() }; + assert_eq!(*width, 1); + assert!(close(rect[2], 10.0)); +} + +#[test] +fn emfplus_reset_clip_and_restore_bring_drawing_back() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let fill = || prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[10.0, 10.0, 5.0, 5.0])])); + let p = plus_pic(vec![prec(0x4025, 0, &h32(1)), clip, fill(), prec(0x4026, 0, &h32(1)), fill()]); + assert_eq!(p.items.len(), 1); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let p = plus_pic(vec![clip, fill(), prec(0x4031, 0, &[]), fill()]); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn emfplus_region_clip_intersects_rectangles() { + // Region: intersect(rect 0,0,30,30 ; rect 10,10,100,100) = 10..30. + let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]); + let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), rect([0.0, 0.0, 30.0, 30.0]), rect([10.0, 10.0, 90.0, 90.0])]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!((p.width, p.height), (20.0, 20.0)); +} + +#[test] +fn emfplus_pie_and_arc() { + // A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100). + let pie = prec(0x4010, 0x8000, &cat(vec![h32(0xFF00_0000), f32s(&[0.0, 90.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pie]); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert_eq!(segs[1], Seg::Line(50.0, 0.0)); + assert!(matches!(segs[2], Seg::Cubic(.., x, y) if close(x, 0.0) && close(y, 50.0)), "{segs:?}"); + assert_eq!(segs[3], Seg::Close); + // A half-circle arc stroke is two cubics. + let pen = prec(0x4008, 0x0200, &solid_pen(0xFF00_0000, 1.0)); + let arc = prec(0x4012, 0, &cat(vec![f32s(&[180.0, -180.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pen, arc]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_convex_clip_path_cuts_to_the_shape_not_its_box() { + // A diamond clip path (0,50) (50,0) (100,50) (50,100): the corner of a full square fill is cut away. + let diamond = cat(vec![h32(0xDBC0_1002), h32(4), h32(0), f32s(&[0.0, 50.0, 50.0, 0.0, 100.0, 50.0, 50.0, 100.0]), vec![0, 1, 1, 0x81]]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0300 | 9, &diamond), prec(0x4033, 9, &[]), fill]); + let (segs, ..) = path_segs(&p.items[0]); + let pts: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Move(x, y) | Seg::Line(x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert!(!pts.iter().any(|(x, y)| close(*x, 0.0) && close(*y, 0.0)), "corner kept: {pts:?}"); + assert!(pts.iter().all(|(x, y)| (x - 50.0).abs() + (y - 50.0).abs() <= 50.0 + 1e-3), "{pts:?}"); +} + +#[test] +fn emfplus_draw_string_wraps_to_the_layout_width() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let text = "alpha beta gamma delta"; + let s = prec( + 0x401C, + 0x8000, + &cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 60.0, 100.0]), u16s(text)]), + ); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 60.0, 100.0])])); + let p = plus_pic(vec![fill, font, s]); + let lines: Vec<&str> = p.items.iter().filter_map(|i| if let Item::Text(t) = i { Some(t.text.as_str()) } else { None }).collect(); + assert_eq!(lines, vec!["alpha beta", "gamma delta"]); +} + +#[test] +fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { + // The nested picture: a plain EMF with a 10 x 10 rectangle (white brush, black pen). + let inner = emf(&[erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))], [0; 4], [0; 4]); + let obj = cat(vec![h32(0xDBC0_1002), h32(2), h32(3), h32(inner.len() as u32), inner]); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 3, &obj), draw_image(3, [0.0, 0.0, 40.0, 20.0])]); + assert_eq!(p.items.len(), 1); + assert_eq!((p.width, p.height), (40.0, 20.0)); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); +} + +/// Curves and a region fill, as an EMF+ only file (also written to `$DECKCRAFT_EMF_OUT` to look at). +fn curves_file() -> Vec { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF20_60C0, 3.0)); + // A closed curve through a diamond's corners, filled orange. + let diamond = f32s(&[100.0, 10.0, 190.0, 100.0, 100.0, 190.0, 10.0, 100.0]); + let fill = prec(0x4016, 0x8000, &cat(vec![h32(0xFFE0_8020), f32s(&[0.5]), h32(4), diamond.clone()])); + let outline = prec(0x4017, 1, &cat(vec![f32s(&[0.5]), h32(4), diamond])); + // An open curve (a smoothed line chart): all four segments of five points. + let line = + prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(4), h32(5), f32s(&[210.0, 150.0, 260.0, 60.0, 310.0, 120.0, 360.0, 30.0, 410.0, 90.0])])); + // A green rectangle region, filled. + let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); + let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); + // A cream background first. + let clear = prec(0x4009, 0, &h32(0xFFF4_EED8)); + let mut all = vec![plus_header(false), clear, pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + all.push(prec(0x4002, 0, &[])); + emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) +} + +#[test] +fn emfplus_cardinal_curves_and_region_fill() { + let bytes = curves_file(); + if let Ok(path) = std::env::var("DECKCRAFT_EMF_OUT") { + let _ = std::fs::write(path, &bytes); + } + let p = parse(&bytes).unwrap(); + assert_eq!(p.items.len(), 5, "{:?}", p.items.len()); + assert_eq!(path_segs(&p.items[0]).1, Some([0xF4, 0xEE, 0xD8, 0xFF]), "the clear comes first"); + // The closed curve: four cubics through the diamond's corners, closed. + let (segs, fill, ..) = path_segs(&p.items[1]); + assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); + let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert_eq!(cubics.len(), 4, "{segs:?}"); + assert_eq!(segs.last(), Some(&Seg::Close)); + // The open curve: four cubics and no close; its controls bulge past the straight chords. + let (segs, fill, stroke, _) = path_segs(&p.items[3]); + assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); + assert!(!segs.contains(&Seg::Close)); + // The region fill is its rectangle. + let (segs, fill, ..) = path_segs(&p.items[4]); + assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_curve_offsets_and_degenerate_input_are_safe() { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_0000, 1.0)); + // Segments from point 2 of 5, asking for more than there are: two segments. + let line = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(2), h32(99), h32(5), f32s(&[0.0, 0.0, 10.0, 5.0, 20.0, 0.0, 30.0, 5.0, 40.0, 0.0])])); + let p = plus_pic(vec![pen.clone(), line]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 2, "{segs:?}"); + // One point, an offset past the end, a NaN tension, a missing region: nothing, no panic. + let one = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(1), h32(1), f32s(&[1.0, 1.0])])); + let past = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(9), h32(9), h32(2), f32s(&[0.0, 0.0, 9.0, 9.0])])); + let nan = prec(0x4017, 1, &cat(vec![f32s(&[f32::NAN]), h32(3), f32s(&[0.0, 0.0, 9.0, 9.0, 0.0, 9.0])])); + let no_region = prec(0x4013, 0x8000 | 7, &h32(0xFF00_0000)); + let p = plus_pic(vec![pen, one, past, nan, no_region]); + assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); +} + +#[test] +fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() { + let dot = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[40.0, 40.0, 10.0, 10.0])])); + let clear = |argb: u32| prec(0x4009, 0, &h32(argb)); + // 100 × 50 device units (0..2500 × 0..1250 hundredths of a millimetre at 0.04 units each). + let file = |recs: Vec>| { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0, 0, 99, 49], [0, 0, 2500, 1250])).unwrap() + }; + // A translated, clipped world doesn't move or cut the clear. + let shift = prec(0x402D, 0, &f32s(&[30.0, 30.0])); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 5.0, 5.0])); + let p = file(vec![shift, clip, clear(0xFFFF_FFFF), prec(0x4031, 0, &[]), dot.clone()]); + assert_eq!(p.items.len(), 2); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + let span = xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min); + assert!((span - p.width).abs() < 0.5, "clear spans {span} of {}", p.width); + // Transparent: only the dot. + assert_eq!(file(vec![clear(0x00FF_FFFF), dot.clone()]).items.len(), 1); + // No frame: nothing to clear. + assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1); +} diff --git a/crates/metafile/src/text.rs b/crates/metafile/src/text.rs index 86bca911..0125e9c5 100644 --- a/crates/metafile/src/text.rs +++ b/crates/metafile/src/text.rs @@ -63,6 +63,9 @@ pub struct Text { pub v_align: VAlign, /// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED). pub clip: Option<[f32; 4]>, + /// Clip region from the device context, in picture units. None is the default clip. Drawn + /// together with [`Text::clip`] when both are set. + pub region: Option>, } /// TA_UPDATECP: the string starts at the current position, which moves past it. diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index 12733e1c..3e02c10b 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -47,6 +47,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { let mut cv = Canvas::new(); if let Some((c, size)) = bounds { cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + cv.set_clip_surface([0.0, 0.0, size.0, size.1]); cv.pattern_px = PT_PER_PX; } let mut player_viewport = bounds.is_some(); @@ -122,6 +123,8 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); }) } + 0x0415 => rect16(r, 6).map(|rect| cv.exclude_clip_rect(rect)), + 0x0416 => rect16(r, 6).map(|rect| cv.intersect_clip_rect(rect)), 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), 0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| { let kind = match func { diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 7c45aa0f..b34fb412 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -21,6 +21,12 @@ pub struct Transition { pub advance_after_ms: Option, /// Embedded sound media. pub sound: Option, + /// Loop Until Next Sound (`p:stSnd loop="1"`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub sound_loop: bool, + /// [Stop Previous Sound] (`p:endSnd`): the slide stops a sound still playing. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, /// Unmodelled transition XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -35,6 +41,8 @@ impl Default for Transition { advance_on_click: true, advance_after_ms: None, sound: None, + sound_loop: false, + stop_sound: false, raw: None, } } @@ -365,6 +373,15 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ ("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]), ("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]), ("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]), + ("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]), + ("boxOut", "Box", AnimClass::Exit, 4, 500, &["in", "out"]), + ("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]), + ("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["in", "out"]), + ("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]), + ("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]), + ("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]), + ("spinnerOut", "Spinner", AnimClass::Exit, 49, 1000, &[]), + ("contract", "Contract", AnimClass::Exit, 55, 500, &[]), ("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]), ("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]), ("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]), @@ -377,6 +394,40 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ &["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"], ), ("loops", "Loops", AnimClass::Path, 61, 2000, &[]), + ("diagonalDownRight", "Diagonal Down Right", AnimClass::Path, 49, 2000, &[]), + ("diagonalUpRight", "Diagonal Up Right", AnimClass::Path, 56, 2000, &[]), + ("zigzag", "Zigzag", AnimClass::Path, 38, 2000, &[]), + ("sineWave", "Sine Wave", AnimClass::Path, 40, 2000, &[]), + ("pathWave", "Wave", AnimClass::Path, 47, 2000, &[]), + ("decayingWave", "Decaying Wave", AnimClass::Path, 60, 2000, &[]), + ("heartbeat", "Heartbeat", AnimClass::Path, 45, 2000, &[]), + ("sCurve1", "S Curve 1", AnimClass::Path, 59, 2000, &[]), + ("sCurve2", "S Curve 2", AnimClass::Path, 39, 2000, &[]), + ("stairsDown", "Stairs Down", AnimClass::Path, 62, 2000, &[]), + ("bounceRight", "Bounce Right", AnimClass::Path, 54, 2000, &[]), + ("bounceLeft", "Bounce Left", AnimClass::Path, 41, 2000, &[]), + ("spiralRight", "Spiral Right", AnimClass::Path, 46, 2000, &[]), + ("spiralLeft", "Spiral Left", AnimClass::Path, 55, 2000, &[]), + ("spring", "Spring", AnimClass::Path, 53, 2000, &[]), + ("funnel", "Funnel", AnimClass::Path, 52, 2000, &[]), + ("swoosh", "Swoosh", AnimClass::Path, 30, 2000, &[]), + ("star4", "4 Point Star", AnimClass::Path, 16, 2000, &[]), + ("star5", "5 Point Star", AnimClass::Path, 5, 2000, &[]), + ("star6", "6 Point Star", AnimClass::Path, 11, 2000, &[]), + ("star8", "8 Point Star", AnimClass::Path, 17, 2000, &[]), + ("heart", "Heart", AnimClass::Path, 9, 2000, &[]), + ("crescentMoon", "Crescent Moon", AnimClass::Path, 6, 2000, &[]), + ("teardrop", "Teardrop", AnimClass::Path, 18, 2000, &[]), + ("football", "Football", AnimClass::Path, 12, 2000, &[]), + ("plusPath", "Plus", AnimClass::Path, 32, 2000, &[]), + ("rightTriangle", "Right Triangle", AnimClass::Path, 2, 2000, &[]), + ("curvedSquare", "Curved Square", AnimClass::Path, 20, 2000, &[]), + ("figure8Horizontal", "Horizontal Figure 8", AnimClass::Path, 26, 2000, &[]), + ("figure8Vertical", "Vertical Figure 8", AnimClass::Path, 22, 2000, &[]), + ("loopDeLoop", "Loop de Loop", AnimClass::Path, 24, 2000, &[]), + ("bean", "Bean", AnimClass::Path, 31, 2000, &[]), + ("peanut", "Peanut", AnimClass::Path, 27, 2000, &[]), + ("neutron", "Neutron", AnimClass::Path, 29, 2000, &[]), ("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]), ("play", "Play", AnimClass::Media, 1, 0, &[]), ("pause", "Pause", AnimClass::Media, 2, 0, &[]), @@ -387,6 +438,149 @@ pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'stat ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) } +/// The path of a motion-path preset beyond Lines, Arcs, Turns, Shapes and Loops, in slide +/// fractions from the shape's position (our path syntax: `M`, `L`, `Z`, ending in `E`). +/// `None` for an effect that isn't one of these presets. +pub fn preset_path(effect: &str) -> Option { + use std::f64::consts::{PI, TAU}; + use std::fmt::Write; + // Sample `f` over 0..=1 into a polyline starting at (0, 0). + fn poly(n: usize, closed: bool, f: impl Fn(f64) -> (f64, f64)) -> String { + let (x0, y0) = f(0.0); + let mut s = String::from("M 0 0"); + for k in 1..=n { + let (x, y) = f(k as f64 / n as f64); + let _ = write!(s, " L {:.4} {:.4}", x - x0, y - y0); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + fn pts(p: &[(f64, f64)], closed: bool) -> String { + let mut s = String::from("M 0 0"); + for (x, y) in p { + let _ = write!(s, " L {x:.4} {y:.4}"); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + // A closed curve in polar form r(θ), starting at its top. + fn polar(scale: f64, r: impl Fn(f64) -> f64) -> String { + poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let rr = r(a + PI / 2.0) * scale; + (rr * a.cos(), rr * a.sin()) + }) + } + fn star(n: usize, outer: f64, inner: f64) -> String { + let mut p = Vec::new(); + let start = (0.0, -outer); + for k in 1..=2 * n { + let a = -PI / 2.0 + PI * k as f64 / n as f64; + let r = if k % 2 == 1 { inner } else { outer }; + p.push((r * a.cos() - start.0, r * a.sin() - start.1)); + } + pts(&p, true) + } + let spiral = |dir: f64| { + poly(120, false, move |t| { + let r = 0.12 * (1.0 - t); + let a = dir * 3.0 * TAU * t; + (dir * (0.12 - r * a.cos()), -r * a.sin()) + }) + }; + let bounce = |dir: f64| poly(90, false, move |t| (dir * 0.3 * t, 0.15 * t - 0.1 * (1.0 - t) * (3.0 * PI * t).sin().abs())); + Some(match effect { + "diagonalDownRight" => pts(&[(0.2, 0.2)], false), + "diagonalUpRight" => pts(&[(0.2, -0.2)], false), + "zigzag" => pts(&[(0.05, 0.04), (0.1, 0.0), (0.15, 0.04), (0.2, 0.0), (0.25, 0.04), (0.3, 0.0)], false), + "sineWave" => poly(64, false, |t| (0.3 * t, 0.04 * (2.0 * TAU * t).sin())), + "pathWave" => poly(48, false, |t| (0.3 * t, 0.06 * (TAU * t).sin())), + "decayingWave" => poly(96, false, |t| (0.3 * t, 0.07 * (-3.0 * t).exp() * (3.0 * TAU * t).sin())), + "heartbeat" => pts(&[(0.08, 0.0), (0.1, -0.08), (0.13, 0.06), (0.15, 0.0), (0.25, 0.0)], false), + "sCurve1" => poly(48, false, |t| (0.2 * t, 0.1 - 0.1 * (PI * t).cos())), + "sCurve2" => poly(48, false, |t| (0.1 - 0.1 * (PI * t).cos(), 0.2 * t)), + "stairsDown" => pts(&[(0.06, 0.0), (0.06, 0.06), (0.12, 0.06), (0.12, 0.12), (0.18, 0.12), (0.18, 0.18)], false), + "bounceRight" => bounce(1.0), + "bounceLeft" => bounce(-1.0), + "spiralRight" => spiral(1.0), + "spiralLeft" => spiral(-1.0), + "spring" => poly(160, false, |t| (0.3 * t + 0.03 * (5.0 * TAU * t).cos(), 0.03 * (5.0 * TAU * t).sin())), + "funnel" => poly(160, false, |t| { + let r = 0.12 * (1.0 - t); + (r * (3.0 * TAU * t).cos(), 0.25 * t + 0.03 * (3.0 * TAU * t).sin() * (1.0 - t)) + }), + "swoosh" => poly(48, false, |t| (0.3 * t, -0.12 * (PI * t).sin() + 0.1 * t * t)), + "star4" => star(4, 0.1, 0.04), + "star5" => star(5, 0.1, 0.04), + "star6" => star(6, 0.1, 0.05), + "star8" => star(8, 0.1, 0.06), + "heart" => poly(96, true, |t| { + let a = TAU * t; + (0.006 * 16.0 * a.sin().powi(3), -0.006 * (13.0 * a.cos() - 5.0 * (2.0 * a).cos() - 2.0 * (3.0 * a).cos() - (4.0 * a).cos())) + }), + "crescentMoon" => { + // The outer arc of a circle, back along a smaller, offset one. + let mut p: Vec<(f64, f64)> = (1..=32) + .map(|k| { + let a = -PI / 2.0 - PI * k as f64 / 32.0; + (0.1 * a.cos(), 0.1 + 0.1 * a.sin()) + }) + .collect(); + p.extend((1..=32).map(|k| { + let a = PI / 2.0 - PI * k as f64 / 32.0; + (0.06 * a.cos() - 0.03, 0.1 + 0.1 * a.sin()) + })); + pts(&p, true) + } + "teardrop" => polar(0.1, |a| 0.6 + 0.4 * (a / 2.0).cos().abs().powf(3.0)), + "football" => poly(64, true, |t| { + let a = TAU * t; + (0.12 * a.sin(), 0.06 * (1.0 - a.cos()) * (1.0 - 0.3 * (2.0 * a).cos().abs())) + }), + "plusPath" => pts( + &[ + (0.04, 0.0), + (0.04, 0.04), + (0.08, 0.04), + (0.08, 0.08), + (0.04, 0.08), + (0.04, 0.12), + (0.0, 0.12), + (0.0, 0.08), + (-0.04, 0.08), + (-0.04, 0.04), + (0.0, 0.04), + ], + true, + ), + "rightTriangle" => pts(&[(0.0, 0.15), (0.15, 0.15)], true), + "curvedSquare" => poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let sg = |v: f64| v.signum() * v.abs().sqrt(); + (0.1 * sg(a.cos()), 0.1 + 0.1 * sg(a.sin())) + }), + "figure8Horizontal" => poly(96, true, |t| { + let a = TAU * t; + (0.15 * a.sin(), 0.06 * (2.0 * a).sin()) + }), + "figure8Vertical" => poly(96, true, |t| { + let a = TAU * t; + (0.06 * (2.0 * a).sin(), 0.15 * (1.0 - a.cos())) + }), + "loopDeLoop" => poly(120, false, |t| (0.3 * t - 0.05 * (2.0 * TAU * t).sin(), -0.05 * (1.0 - (2.0 * TAU * t).cos()))), + "bean" => polar(0.1, |a| 0.75 + 0.25 * a.cos() + 0.15 * (2.0 * a).cos()), + "peanut" => polar(0.1, |a| 0.7 + 0.3 * (2.0 * a).cos().abs()), + "neutron" => polar(0.1, |a| 0.35 + 0.65 * (3.0 * a).cos().abs()), + _ => return None, + }) +} + #[cfg(test)] mod option_label_tests { use super::*; diff --git a/crates/model/src/bidi.rs b/crates/model/src/bidi.rs new file mode 100644 index 00000000..0b113522 --- /dev/null +++ b/crates/model/src/bidi.rs @@ -0,0 +1,81 @@ +//! Bidirectional text helpers (UAX #9). +//! +//! Follows Unicode Bidirectional Algorithm (UAX #9 rules P2 and P3) for paragraph +//! base direction auto-detection. + +/// Does the first strong directional character in `text` belong to a right-to-left script? +/// +/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R +/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek, +/// Cyrillic, CJK…) give `false`. Neutral, weak, and whitespace characters are skipped. +pub fn first_strong_is_rtl(text: &str) -> bool { + for c in text.chars() { + if c == '\u{200F}' { + // Right-to-Left Mark (RLM) + return true; + } + if c == '\u{200E}' { + // Left-to-Right Mark (LRM) + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + +/// Helper checking if a char belongs to standard RTL Unicode blocks. +pub fn is_rtl_script(c: char) -> bool { + matches!( + c, + '\u{0590}'..='\u{05FF}' // Hebrew + | '\u{0600}'..='\u{06FF}' // Arabic + | '\u{0700}'..='\u{074F}' // Syriac + | '\u{0750}'..='\u{077F}' // Arabic Supplement + | '\u{0780}'..='\u{07BF}' // Thaana + | '\u{0870}'..='\u{089F}' // Arabic Extended-B + | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A + | '\u{FB1D}'..='\u{FB4F}' // Hebrew Presentation Forms + | '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A + | '\u{FE70}'..='\u{FEFE}' // Arabic Presentation Forms-B + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_arabic_and_kurdish() { + assert!(first_strong_is_rtl("مرحبا")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl("سلام")); + } + + #[test] + fn detects_hebrew() { + assert!(first_strong_is_rtl("שלום")); + } + + #[test] + fn detects_latin_as_not_rtl() { + assert!(!first_strong_is_rtl("Hello")); + assert!(!first_strong_is_rtl("DeckCraft")); + } + + #[test] + fn skips_neutrals_numbers_and_whitespace() { + assert!(first_strong_is_rtl(" 123 مرحبا")); + assert!(first_strong_is_rtl("... (2024) سنة")); + assert!(!first_strong_is_rtl(" 123 Hello")); + assert!(!first_strong_is_rtl("")); + assert!(!first_strong_is_rtl(" 12345 ")); + } + + #[test] + fn honours_directional_marks() { + assert!(first_strong_is_rtl("\u{200F}Hello")); + assert!(!first_strong_is_rtl("\u{200E}مرحبا")); + } +} diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..36dd9708 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -34,10 +34,29 @@ pub enum ChartType { Funnel, Treemap, Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, Combo, } impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap + } + pub const MAIN: [ChartType; 12] = [ ChartType::Column, ChartType::Bar, @@ -80,6 +99,7 @@ impl ChartType { ChartType::Funnel => "Funnel", ChartType::Treemap => "Treemap", ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", ChartType::Combo => "Combo", } } @@ -116,6 +136,87 @@ impl ChartType { } } +/// `c:trendlineType`: the six trendlines DrawingML charts store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TrendlineType { + Exp, + #[default] + Linear, + Log, + MovingAvg, + Poly, + Power, +} + +impl TrendlineType { + pub fn xml(self) -> &'static str { + match self { + TrendlineType::Exp => "exp", + TrendlineType::Linear => "linear", + TrendlineType::Log => "log", + TrendlineType::MovingAvg => "movingAvg", + TrendlineType::Poly => "poly", + TrendlineType::Power => "power", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "exp" => TrendlineType::Exp, + "linear" => TrendlineType::Linear, + "log" => TrendlineType::Log, + "movingAvg" => TrendlineType::MovingAvg, + "poly" => TrendlineType::Poly, + "power" => TrendlineType::Power, + _ => return None, + }) + } +} + +/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Trendline { + pub kind: TrendlineType, + /// `c:name`, when the file sets one. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Polynomial order (`c:order`). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Moving-average period (`c:period`). + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). + #[serde(skip_serializing_if = "Option::is_none")] + pub forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backward: Option, + /// Cross the value axis at this value (`c:intercept`). + #[serde(skip_serializing_if = "Option::is_none")] + pub intercept: Option, + /// Show the equation (`c:dispEq`). + pub disp_eq: bool, + /// Show R² (`c:dispRSqr`). + pub disp_r_sqr: bool, +} + +impl Default for Trendline { + fn default() -> Self { + Trendline { + kind: TrendlineType::Linear, + name: None, + order: None, + period: None, + forward: None, + backward: None, + intercept: None, + disp_eq: false, + disp_r_sqr: false, + } + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Series { @@ -138,6 +239,79 @@ pub struct Series { /// source data's. #[serde(skip_serializing_if = "Option::is_none")] pub number_format: Option, + /// Trendlines (`c:trendline`), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -159,6 +333,8 @@ pub struct Chart { pub title_size: Option, pub data_labels: bool, pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, pub axis_titles: (Option, Option), /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). pub style: u32, @@ -174,10 +350,15 @@ pub struct Chart { pub y_max: Option, /// The value axis' major unit, when the chart fixes it. pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, /// A combo chart's secondary value axis: bounds, major unit and number format. pub y2_min: Option, pub y2_max: Option, @@ -186,9 +367,64 @@ pub struct Chart { pub y2_hidden: bool, /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, + /// Package parts the original chart still points at (embedded workbook, styles, drawings). + /// The writer clears this before comparing modelled state. A native save does not store the bytes. + #[serde(skip)] + pub kept: ChartKept, +} + +/// One relationship of a chart part, with the target string as it was written. +#[derive(Clone, Debug, PartialEq)] +pub struct ChartRel { + pub id: String, + pub rel_type: String, + pub target: String, + /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` + /// up to case); empty for external ones. The writer renames kept parts and uses this to + /// point the relationship at the new name. + pub part: String, + pub external: bool, +} + +/// A part reached from a chart (the workbook, a drawing, an image it uses). +#[derive(Clone, Debug, PartialEq)] +pub struct ChartPart { + /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). + pub name: String, + pub content_type: String, + pub bytes: Vec, +} + +/// Sidecar for an unchanged chart: relationships and the parts they reach. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChartKept { + pub rels: Vec, + pub parts: Vec, } impl Default for Chart { @@ -204,6 +440,7 @@ impl Default for Chart { title_size: None, data_labels: false, gridlines: true, + minor_gridlines: false, axis_titles: (None, None), style: 2, palette: "colorful1".into(), @@ -217,15 +454,24 @@ impl Default for Chart { y_min: None, y_max: None, y_major: None, + y_log_base: None, y_format: None, y_hidden: false, + cat_reversed: false, y2_min: None, y2_max: None, y2_major: None, y2_format: None, y2_hidden: false, lang: None, + subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, + up_down_bars: false, + bin_count: None, + bin_size: None, raw: None, + kept: ChartKept::default(), } } } @@ -236,6 +482,45 @@ impl Chart { let cats = ["Q1", "Q2", "Q3", "Q4"]; let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + // A two-level hierarchy: months inside quarters. + c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); + c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c.vary_colors = true; + return c; + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } + if kind == ChartType::Stock { + // High-low-close over five days, the series in the order PowerPoint expects. + c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + return c; + } let n_series = if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; for s in 0..n_series { @@ -247,6 +532,12 @@ impl Chart { }); } c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } c } /// A series' own chart type: its own in a combo chart (columns when it names none), else the @@ -271,50 +562,120 @@ impl Chart { if self.kind != ChartType::Combo { return (!secondary).then(|| self.value_range()); } + let (lo, hi) = self.plotted_extent(secondary)?; + let (lo, hi) = (lo.min(0.0), hi.max(0.0)); + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + + /// The smallest and largest value plotted against one value axis: plain values as they are, + /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite + /// values; `None` when no series uses the axis. + fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); if ser.is_empty() { - return None; + return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); } - let (mut lo, mut hi) = (0.0_f64, 0.0_f64); + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); let mut percent = false; for i in 0..n { - let (mut p, mut m) = (0.0, 0.0); + let (mut p, mut m, mut stacked) = (0.0, 0.0, false); for s in &ser { let k = self.series_kind(s); let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; if k.percent() { percent = true; } else if k.stacked() { + stacked = true; if v >= 0.0 { p += v } else { m += v } } else { hi = hi.max(v); lo = lo.min(v); } } - hi = hi.max(p); - lo = lo.min(m); + if stacked { + hi = hi.max(p); + lo = lo.min(m); + } + } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } } if percent { + lo = lo.min(0.0); hi = hi.max(1.0); } - Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) + } + + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() } /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: - /// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 % - /// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis. + /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). + /// `None` when no series uses the axis. pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { - let (lo, hi) = self.axis_range(secondary)?; + let (lo, hi) = self.plotted_extent(secondary)?; let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; let full_percent = !secondary && self.kind.percent(); Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) } + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + /// A value axis label in the axis' number format and the chart's language. pub fn axis_label(&self, secondary: bool, v: f64) -> String { let code = if secondary { &self.y2_format } else { &self.y_format }; - crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref()) + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, v, self.lang.as_deref()) } /// A data label of series `s`: its number format, else its axis', in the chart's language. @@ -328,6 +689,13 @@ impl Chart { pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { for i in 0..n { @@ -352,13 +720,28 @@ impl Chart { if self.kind.percent() { return (0.0, 1.0); } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) } } /// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` -/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`) -/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals. +/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest +/// 1-2-5 unit giving at most `max_intervals` intervals: +/// +/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. +/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise +/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts +/// near them. +/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. +/// Mixed signs get headroom at both ends. pub fn axis_scale( lo: f64, hi: f64, @@ -372,17 +755,28 @@ pub fn axis_scale( let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); let (fmin, fmax) = (fin(fmin), fin(fmax)); let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; - let mut lo = fmin.unwrap_or(lo.min(0.0)); - let mut hi = fmax.unwrap_or(hi.max(lo)); + // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier + // summarises it): 5 % of the data range past the data, and zero when the values don't + // sit within the top sixth of their largest magnitude. A single value is measured from zero. + let flat = hi - lo < 1e-12; + let pad = if !headroom { + 0.0 + } else if flat { + hi.abs() / 20.0 + } else { + (hi - lo) / 20.0 + }; + let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; + let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; + let mut lo = fmin.unwrap_or(auto_lo); + let mut hi = fmax.unwrap_or(auto_hi); if hi - lo < 1e-12 { - hi = lo + lo.abs().max(1.0); - } - let span = hi - lo; - if headroom && fmax.is_none() && hi > 0.0 { - hi += span * 0.05; - } - if headroom && fmin.is_none() && lo < 0.0 { - lo -= span * 0.05; + // A fixed limit on the far side of the data: the automatic end gives way. + match (fmin, fmax) { + (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, + (Some(_), None) if lo < 0.0 => hi = 0.0, + _ => hi = lo + lo.abs().max(1.0), + } } let n = max_intervals.clamp(1, 50) as f64; let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { @@ -431,6 +825,34 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); @@ -462,6 +884,130 @@ mod tests { } } + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// The primary axis of a chart with room for ten intervals. + fn scale(c: &Chart) -> (f64, f64, f64) { + c.axis_scale(false, 10).unwrap_or_default() + } + + /// Office's documented automatic limits (#144), with room for ten intervals. + #[test] + fn automatic_axis_matches_office() { + // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. + assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(scale(&c), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. + assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); + assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); + // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. + assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below + // min − (max − min)/20. + assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); + assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); + assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); + // All-negative data mirrors it: the axis ends at zero, or near the data. + assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); + assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); + assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); + // Mixed signs get headroom at both ends. + assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); + // Stacked bars build up from zero, so their axis keeps it. + let mut s = column(&[85.0, 92.0]); + s.kind = ChartType::StackedColumn; + assert_eq!(scale(&s).0, 0.0); + // A combo chart's secondary axis follows the same rule. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); + assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(scale(&c), (10.0, 45.0, 5.0)); + // A fixed limit beyond the data: the automatic end gives way to it. + let mut c = column(&[85.0, 92.0, 97.0]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + let mut c = column(&[-50.0, -48.0]); + c.y_min = Some(-10.0); + assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); + } + + #[test] + fn percent_axis_is_zero_to_one() { + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(scale(&p), (0.0, 1.0, 0.1)); + assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); + } + + #[test] + fn degenerate_axes_are_finite() { + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { + let (min, max, step) = scale(&column(&[a, b])); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } + let (min, max, _) = scale(&column(&[7.0, 7.0])); + assert!(min == 0.0 && max > 7.0); + assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); + for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { + let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); + } + } + + #[test] + fn general_labels_keep_every_digit_the_data_has() { + // #324: labels were rounded to one decimal ("100000.0" for 99999.99). + for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { + let c = Chart::sample(kind); + let s = &c.series[0]; + for (v, want) in + [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] + { + assert_eq!(c.data_label(s, v), want, "{kind:?}"); + } + } + // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). + let c = Chart::sample(ChartType::Column); + let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); + assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); + } + + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + #[test] fn axis_labels_use_the_format_and_language() { let mut c = Chart::sample(ChartType::Column); @@ -480,6 +1026,16 @@ mod tests { assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); } + #[test] + fn full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + #[test] fn combo_axis_ranges() { // Spend in columns on the primary axis, clicks as a line on the secondary one. @@ -510,3 +1066,376 @@ mod tests { assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); } } + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats_by(pts, self.inclusive_quartiles) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..19adc7bb 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -513,18 +513,26 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen next_id: next.max(256), embedded_fonts: vec![], table_styles: vec![], + tags: vec![], + kept_parts: vec![], + guides: vec![], raw_ext: None, }; if title_slide { - let s = new_slide(&mut p, first_layout); - p.slides.push(Arc::new(s)); + // A fresh deck starts at id 256 and this builds a handful of ids, so the slide always + // fits; should that ever stop holding, the deck arrives without its title slide rather + // than with a duplicated id. + if let Some(s) = new_slide(&mut p, first_layout) { + p.slides.push(Arc::new(s)); + } } p } -/// A slide for `layout` with its placeholders instantiated (empty, inheriting position). -pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide { - let id = SlideId(p.alloc_id()); +/// A slide for `layout` with its placeholders instantiated (empty, inheriting position). `None` +/// when the deck has used every object id and cannot grow (#407). +pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Option { + let id = SlideId(p.alloc_id()?); let mut shapes = vec![]; let lay_shapes: Vec = p.layout(layout).map(|(_, l)| l.shapes.clone()).unwrap_or_default(); for ls in lay_shapes { @@ -532,7 +540,7 @@ pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide { if ph.kind.is_footer_kind() { continue; } - let sid = ShapeId(p.alloc_id()); + let sid = ShapeId(p.alloc_id()?); shapes.push(Shape { id: sid, name: ls.name.clone(), @@ -543,7 +551,7 @@ pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide { ..Default::default() }); } - Slide { id, layout, shapes, ..Default::default() } + Some(Slide { id, layout, shapes, ..Default::default() }) } /// Layout for a kind in the first master. diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index af082b89..1c38bf74 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -88,7 +88,8 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option let Some(p) = body.paragraphs.get_mut(pi) else { break }; let idx = split_at(p, ch); let tail: Vec = p.runs.drain(idx..).collect(); - let newp = Paragraph { level: p.level, props: p.props.clone(), runs: tail, end_props: p.end_props.clone() }; + let newp = + Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() }; p.end_props = props.clone(); body.paragraphs.insert(pi + 1, newp); pi += 1; @@ -400,6 +401,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { } } +/// The position of the `n`th character of the body's plain text (`\n` between paragraphs). +fn pos_of_char(body: &TextBody, mut n: usize) -> Pos { + for (pi, p) in body.paragraphs.iter().enumerate() { + let len = p.char_len(); + if n <= len { + return (pi, n); + } + n -= len + 1; + } + end(body) +} + +/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in +/// place: only the span between the longest common prefix and suffix changes, so untouched text +/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text +/// that shares nothing with the old text replaces it as plain text. +pub fn replace_text(body: &mut TextBody, text: &str) { + let old: Vec = body.text().chars().collect(); + let new: Vec = text.chars().collect(); + if old == new { + return; + } + let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let room = old.len().min(new.len()) - pre; + let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count(); + if pre == 0 && suf == 0 { + *body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) }; + return; + } + let a = pos_of_char(body, pre); + let b = pos_of_char(body, old.len() - suf); + let at = delete(body, a, b); + let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect(); + if !mid.is_empty() { + insert(body, at, &mid, None); + } +} + /// End of the body. pub fn end(body: &TextBody) -> Pos { let pi = body.paragraphs.len().saturating_sub(1); @@ -675,4 +714,53 @@ mod tests { assert_eq!(next_case("hello. world is HERE"), "upper"); assert_eq!(next_case("123"), "upper"); } + + /// Speaker notes: a bold heading over indented bullet points. + fn notes() -> TextBody { + use crate::text::{Bullet, ParaProps}; + let mut b = body("Overview\nFirst point\nSecond point"); + format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true)); + for p in b.paragraphs.iter_mut().skip(1) { + p.level = 1; + p.props = + ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + } + b + } + + #[test] + fn replace_text_keeps_formatting() { + let before = notes(); + // Same text: nothing changes. + let mut b = before.clone(); + replace_text(&mut b, "Overview\nFirst point\nSecond point"); + assert_eq!(b, before); + // A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet. + replace_text(&mut b, "Overview\nFirst main point\nSecond point"); + assert_eq!(b.text(), "Overview\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props); + assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1)); + // Typing at the end of the heading continues it in bold. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs.len(), 1); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter after a bullet starts another bullet; deleting one joins nothing else. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point"); + assert_eq!(b.paragraphs.len(), 4); + assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props)); + replace_text(&mut b, "Overview (5 min)\nSecond point"); + assert_eq!(b.text(), "Overview (5 min)\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].level, 1); + // Text with nothing in common is new plain text. + replace_text(&mut b, "Plain"); + assert_eq!(b, body("Plain")); + replace_text(&mut b, ""); + assert!(b.is_empty()); + // Into an empty body. + let mut e = TextBody::default(); + replace_text(&mut e, "a\nb"); + assert_eq!(e.text(), "a\nb"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..275f9440 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod anim; +pub mod bidi; pub mod chart; pub mod defaults; pub mod edit; @@ -17,6 +18,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +mod trendline; pub mod words; use std::sync::Arc; @@ -26,6 +28,7 @@ pub use deckcraft_geom::{Rect, Size, Xfrm}; use serde::{Deserialize, Serialize}; pub use anim::{AnimClass, AnimStart, Animation, Transition}; +pub use bidi::{first_strong_is_rtl, is_rtl_script}; pub use chart::Chart; pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; pub use table::{Cell, Table}; @@ -249,7 +252,8 @@ pub struct MediaClip { pub height: u32, } -/// One ink stroke (Draw tab): points in slide coordinates with pressure. +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct InkStroke { @@ -259,6 +263,24 @@ pub struct InkStroke { pub highlighter: bool, } +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum ShapeKind { @@ -282,12 +304,23 @@ pub enum ShapeKind { Ink { strokes: Vec, }, + /// A 3-D model (`p:contentPart` with a `model3d` relationship, usually a `.glb`). + /// `poster` is the fallback picture an `mc:AlternateContent` choice carried for older readers. + Model3d { + media: MediaId, + #[serde(default)] + poster: Option, + }, /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its /// fallback picture, if any, is shown. Opaque { xml: String, preview: Option, label: String, + /// The parts its XML points to (SmartArt data, an OLE embedding…), so a save writes them + /// back: (relationship id used in `xml`, relationship type, the part as a media item). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + parts: Vec<(String, String, MediaId)>, }, } @@ -317,9 +350,63 @@ pub struct Shape { pub decorative: bool, pub click: Option, pub hover: Option, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, /// Unmodelled XML kept for round-trip (extension lists etc.). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, + /// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagram: Option, +} + +/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Diagram { + /// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle, + /// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix. + pub kind: String, + pub nodes: Vec, + /// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`. + #[serde(skip_serializing_if = "String::is_empty")] + pub style: String, + /// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`. + #[serde(skip_serializing_if = "String::is_empty")] + pub colors: String, + /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them + /// isn't mapped back to nodes (the first outline edit regenerates them). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, + /// PowerPoint's layout id (`layoutDef@uniqueId`) the diagram came with. + #[serde(skip_serializing_if = "String::is_empty")] + pub layout_id: String, + /// The file's own layout, quick style and colour definitions, written back unchanged while the + /// layout isn't switched. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub colors_xml: Option, +} + +/// One line of the Text Pane: its text and outline level (0 = top level). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DiagramNode { + pub text: String, + pub level: u8, + /// The picture shown in the node's picture placeholder (picture layouts). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// An assistant (organisation charts): drawn beside its parent, off the row of its siblings. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub assistant: bool, + /// The job title under the name (Name and Title Organization Chart). + #[serde(skip_serializing_if = "String::is_empty")] + pub title: String, } impl Default for Shape { @@ -344,7 +431,9 @@ impl Default for Shape { decorative: false, click: None, hover: None, + tags: vec![], raw_ext: None, + diagram: None, } } } @@ -388,6 +477,7 @@ impl Shape { ShapeKind::Media(m) if m.video => "video", ShapeKind::Media(_) => "audio", ShapeKind::Ink { .. } => "ink", + ShapeKind::Model3d { .. } => "model", ShapeKind::Opaque { .. } => "object", } } @@ -534,7 +624,10 @@ pub struct Slide { /// the layout. #[serde(skip_serializing_if = "Option::is_none")] pub color_map: Option>, - /// Unmodelled XML kept for round-trip (custom data, tags). + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Unmodelled XML kept for round-trip (custom data). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } @@ -554,6 +647,7 @@ impl Default for Slide { notes: TextBody::default(), comments: vec![], color_map: None, + tags: vec![], raw_ext: None, } } @@ -802,6 +896,35 @@ pub struct Properties { pub revision: u32, } +/// One typeface embedded in the presentation (`p:embeddedFont`). Each slot is a font file in +/// [`Presentation::media`]: regular, bold, italic, bold italic. A slot is absent when the file +/// didn't include that face. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EmbeddedFont { + pub typeface: String, + /// `panose` on `p:font`, when the file gave the 20 hex digits. + #[serde(skip_serializing_if = "Option::is_none")] + pub panose: Option, + /// `pitchFamily` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub pitch_family: Option, + /// `charset` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub charset: Option, + pub regular: Option, + pub bold: Option, + pub italic: Option, + pub bold_italic: Option, +} + +impl EmbeddedFont { + /// The four face slots, in the order the package stores them. + pub fn faces(&self) -> [Option; 4] { + [self.regular, self.bold, self.italic, self.bold_italic] + } +} + /// An embedded file (image, audio, video, font, workbook). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -817,11 +940,37 @@ pub struct MediaItem { pub link: Option, } +/// A size whose sides may be missing, null or not numbers (NaN then); see `Presentation::slide_size`. +fn lenient_size<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let v = serde_json::Value::deserialize(d)?; + let side = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).unwrap_or(f64::NAN); + Ok(Size::new(side("width"), side("height"))) +} + +/// An XML package part DeckCraft doesn't model, kept as written. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct KeptPart { + /// Part name from the package root (`customXml/item1.xml`). + pub name: String, + /// Content type; empty for a relationships part. + pub content_type: String, + pub xml: String, + /// Full relationship type linking it, empty when only its sibling parts point at it. + pub rel_type: String, + /// Linked from the package root rather than from the presentation part. + pub from_root: bool, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Presentation { - /// Slide size in points (default 16:9, 960 × 540). + /// Slide size in points (default 16:9, 960 × 540). Read leniently: a missing, null or + /// non-numeric side becomes NaN for `deckcraft_format::repair` to replace, instead of the + /// whole file failing to open. + #[serde(deserialize_with = "lenient_size")] pub slide_size: Size, + #[serde(deserialize_with = "lenient_size")] pub notes_size: Size, pub slides: Vec>, pub masters: Vec>, @@ -838,16 +987,35 @@ pub struct Presentation { pub first_slide_number: u32, /// Next free shape/slide/layout/media id. pub next_id: u32, - /// Embedded fonts (family names; bytes in `media`). - pub embedded_fonts: Vec<(String, MediaId)>, + /// Embedded fonts. The face bytes live in [`Self::media`]. + pub embedded_fonts: Vec, /// Table styles defined in the file (`ppt/tableStyles.xml`). #[serde(skip_serializing_if = "Vec::is_empty")] pub table_styles: Vec, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Package parts kept verbatim for round trip: custom XML data (SharePoint metadata), the + /// sensitivity label and custom document properties. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept_parts: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + impl Default for Presentation { fn default() -> Self { defaults::new_presentation(None) @@ -855,10 +1023,13 @@ impl Default for Presentation { } impl Presentation { - /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. - pub fn alloc_id(&mut self) -> u32 { - self.next_id = self.next_id.max(1).saturating_add(1); - self.next_id + /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. `None` + /// once every id is in use (a hostile file can arrive that way): the deck cannot grow + /// further, and callers must refuse the change rather than duplicate ids (#407). + pub fn alloc_id(&mut self) -> Option { + let next = self.next_id.max(1).checked_add(1)?; + self.next_id = next; + Some(next) } /// Make sure `next_id` is above every id in use (after loading a file). pub fn fix_next_id(&mut self) { @@ -878,6 +1049,10 @@ impl Presentation { for md in &self.media { m = m.max(md.id.0); } + for extra in [self.notes_master.as_deref(), self.handout_master.as_deref()].into_iter().flatten() { + m = m.max(extra.id.0); + walk(&extra.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } self.next_id = m; } pub fn slide_index(&self, id: SlideId) -> Option { @@ -910,13 +1085,13 @@ impl Presentation { self.media.iter().find(|m| m.id == id) } /// Add media bytes, reusing an identical existing item. - pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> MediaId { + pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> Option { if let Some(m) = self.media.iter().find(|m| m.data.len() == data.len() && *m.data == data) { - return m.id; + return Some(m.id); } - let id = MediaId(self.alloc_id()); + let id = MediaId(self.alloc_id()?); self.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None }); - id + Some(id) } /// Section index containing a slide (by position), when sections exist. pub fn section_of(&self, slide: SlideId) -> Option { diff --git a/crates/model/src/numfmt.rs b/crates/model/src/numfmt.rs index d6f613dd..c3cf1713 100644 --- a/crates/model/src/numfmt.rs +++ b/crates/model/src/numfmt.rs @@ -162,6 +162,86 @@ pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect(); return s.trim().to_string(); }; + let out = sci(&toks, v, sep, dec).unwrap_or_else(|| fixed(&toks, first, last, v, sep, dec)); + let out = out.trim(); + if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() } +} + +/// The text of the literal and percent tokens in `ts`. +fn lits(ts: &[Tok]) -> String { + ts.iter() + .filter_map(|t| match t { + Tok::Lit(s) => Some(s.clone()), + Tok::Percent => Some("%".into()), + _ => None, + }) + .collect() +} + +/// Scientific notation (`0.00E+00`, `##0.0E-0`): the mantissa takes the placeholders before the `E`, +/// the exponent's sign and minimum width come from what follows it; with several integer +/// placeholders the exponent is a multiple of their count (engineering notation). `None` if the +/// section has no exponent part. +fn sci(toks: &[Tok], v: f64, sep: char, dec: char) -> Option { + let e = toks.iter().position(|t| matches!(t, Tok::Lit(s) if s.as_str() == "E" || s.as_str() == "e"))?; + let e_char = if let Some(Tok::Lit(s)) = toks.get(e) { s.as_str() } else { "E" }; + let plus = match toks.get(e + 1)? { + Tok::Lit(s) if s.as_str() == "+" => true, + Tok::Lit(s) if s.as_str() == "-" => false, + _ => return None, + }; + let mant = toks.get(..e)?; + let mfirst = mant.iter().position(|t| matches!(t, Tok::Digit(_)))?; + let mlast = mant.iter().rposition(|t| matches!(t, Tok::Digit(_)))?; + let exp_toks = toks.get(e + 2..)?; + let exp_len = exp_toks.iter().take_while(|t| matches!(t, Tok::Digit(_))).count(); + if exp_len == 0 { + return None; + } + let width = exp_toks.iter().take(exp_len).filter(|t| **t == Tok::Digit('0')).count(); + let rest = exp_toks.get(exp_len..).unwrap_or(&[]); + let mant_frac = mant.iter().skip_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count(); + let (mut m, mut exp) = (0.0, 0i32); + if v != 0.0 { + exp = v.log10().floor() as i32; + m = v / 10f64.powi(exp); + if !m.is_finite() { + m = 0.0; + exp = 0; + } + if m >= 10.0 { + m /= 10.0; + exp += 1; + } else if m < 1.0 { + m *= 10.0; + exp -= 1; + } + // Rounding the mantissa to its digits can reach 10 (9.999 as `0.00`). + if format!("{m:.mant_frac$}").starts_with("10") { + m /= 10.0; + exp += 1; + } + } + let step = mant.iter().take_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count().max(1) as i32; + let shift = exp.rem_euclid(step); + exp -= shift; + m *= 10f64.powi(shift); + let mantissa = fixed(mant, mfirst, mlast, m, sep, dec); + let sign = if exp < 0 { + "-" + } else if plus { + "+" + } else { + "" + }; + let digits = exp.unsigned_abs().to_string(); + let pad = "0".repeat(width.saturating_sub(digits.len())); + Some(format!("{mantissa}{e_char}{sign}{pad}{digits}{}", lits(rest))) +} + +/// `v` (not negative) in the fixed-point format whose tokens are `toks`, with its digits from +/// `first` to `last`. +fn fixed(toks: &[Tok], first: usize, last: usize, v: f64, sep: char, dec: char) -> String { let percent = toks.contains(&Tok::Percent); let number = toks.get(first..=last).unwrap_or(&[]); let dot = number.iter().position(|t| *t == Tok::Dot); @@ -195,20 +275,9 @@ pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { num.push(dec); num.push_str(&fp); } - let lits = |ts: &[Tok]| -> String { - ts.iter() - .filter_map(|t| match t { - Tok::Lit(s) => Some(s.clone()), - Tok::Percent => Some("%".into()), - _ => None, - }) - .collect() - }; let before = lits(toks.get(..first).unwrap_or(&[])); let after = lits(toks.get(last + 1..).unwrap_or(&[])); - let out = format!("{before}{num}{after}"); - let out = out.trim(); - if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() } + format!("{before}{num}{after}") } #[cfg(test)] @@ -245,6 +314,24 @@ mod tests { assert_eq!(format("\"x\"0", 5.0, None), "x5"); } + #[test] + fn scientific_formats() { + assert_eq!(format("0.00E+00", 1234.0, None), "1.23E+03"); + assert_eq!(format("0.00E+00", 1234.0, Some("ru-RU")), "1,23E+03"); + assert_eq!(format("0.00E+00", 0.00012, None), "1.20E-04"); + assert_eq!(format("0.00E+00", -1234.0, None), "-1.23E+03"); + assert_eq!(format("0.00E+00", 0.0, None), "0.00E+00"); + assert_eq!(format("0.0E+000", 5e10, None), "5.0E+010"); + assert_eq!(format("0.0E-0", 1234.0, None), "1.2E3"); + assert_eq!(format("0.0E-0", 0.0012, None), "1.2E-3"); + assert_eq!(format("0E+00", 9999.0, None), "1E+04"); + assert_eq!(format("0.00E+00", 9.999, None), "1.00E+01"); + assert_eq!(format("0.00e+00", 1234.0, None), "1.23e+03"); + assert_eq!(format("##0.0E+0", 12_345.0, None), "12.3E+3"); + assert_eq!(format("##0.0E+0", 0.012, None), "12.0E-3"); + assert_eq!(format("##0.0E+0", 999.96, None), "1.0E+3"); + } + #[test] fn never_panics() { for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] { diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index acaff2b5..a52e8a67 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -241,6 +241,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { warp_adj: Vec::new(), rtl_col: Some(false), upright: Some(false), + space_first_last: Some(false), }); b } @@ -284,16 +285,22 @@ pub fn level_style(ctx: &Ctx, shape: &Shape, level: u8) -> LevelStyle { /// Fully resolved run properties for `run` in paragraph `para` (explicit → paragraph style). pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunProps) -> RunProps { let mut r = run.clone(); + // Paragraph `defRPr` fills gaps in the run, and the list style (including the layout + // placeholder this shape inherits from) fills whatever is still unset. + r.inherit(¶.def_props); let lvl = level_style(ctx, shape, para.level); r.inherit(&lvl.run); // Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint, // unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline // (`u="none"` removes it). Colours and underlines from list styles don't count. if r.link.is_some() { - if run.fill.is_none() { + // A colour or underline on the run, or on the same `defRPr` that carries the link, is + // kept. A colour that arrived only from a list style is not. + let from_def = run.link.is_none(); + if run.fill.is_none() && !(from_def && para.def_props.fill.is_some()) { r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); } - if run.underline.is_none() { + if run.underline.is_none() && !(from_def && para.def_props.underline.is_some()) { r.underline = Some("sng".into()); } } diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index efb30e48..b067c904 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -143,6 +143,18 @@ pub struct PictureFill { /// flipped. #[serde(skip_serializing_if = "std::ops::Not::not")] pub upright: bool, + /// An artistic effect (Picture Format ▸ Artistic Effects). `media` is then the processed + /// picture; the original is kept to change or remove the effect. + #[serde(skip_serializing_if = "Option::is_none")] + pub artistic: Option, +} + +/// An applied artistic effect: its `a14` name (e.g. `artisticBlur`) and the original picture. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artistic { + pub effect: String, + pub original: MediaId, } /// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 2dd46e3b..1abd1177 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -106,39 +106,120 @@ impl Table { pub fn height(&self) -> f64 { self.rows.iter().map(|r| r.height).sum() } + /// Insert an empty row before `at`. A merge the new row falls inside grows to cover it. pub fn insert_row(&mut self, at: usize) { let at = at.min(self.rows.len()); + let merges = self.merges(); let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0); self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() }); + self.set_merges(merges.into_iter().map(|m| m.insert_row(at))); } + /// Insert an empty column before `at`. A merge the new column falls inside grows to cover it. pub fn insert_col(&mut self, at: usize) { let at = at.min(self.cols.len()); + let merges = self.merges(); let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0); self.cols.insert(at, w); for r in &mut self.rows { let i = at.min(r.cells.len()); r.cells.insert(i, Cell::new("")); } + self.set_merges(merges.into_iter().map(|m| m.insert_col(at))); } + /// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next row it covers. pub fn delete_row(&mut self, at: usize) -> bool { if self.rows.len() <= 1 || at >= self.rows.len() { return false; } + let merges = self.merges(); + for m in &merges { + if m.r == at && m.rs > 1 { + self.move_anchor((m.r, m.c), (m.r + 1, m.c)); + } + } self.rows.remove(at); + self.set_merges(merges.into_iter().filter_map(|m| m.delete_row(at))); true } + /// Delete column `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next column it covers. pub fn delete_col(&mut self, at: usize) -> bool { if self.cols.len() <= 1 || at >= self.cols.len() { return false; } + let merges = self.merges(); + for m in &merges { + if m.c == at && m.gs > 1 { + self.move_anchor((m.r, m.c), (m.r, m.c + 1)); + } + } self.cols.remove(at); for r in &mut self.rows { if at < r.cells.len() { r.cells.remove(at); } } + self.set_merges(merges.into_iter().filter_map(|m| m.delete_col(at))); true } + /// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid. + fn merges(&self) -> Vec { + let (nr, nc) = (self.rows.len(), self.cols.len()); + let mut out = vec![]; + for (r, row) in self.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate().take(nc) { + if cell.is_covered() { + continue; + } + let rs = (cell.row_span.max(1) as usize).min(nr - r); + let gs = (cell.grid_span.max(1) as usize).min(nc - c); + if rs > 1 || gs > 1 { + out.push(Merge { r, c, rs, gs }); + } + } + } + out + } + /// Rebuild every cell's spans and merge flags from `merges`. + fn set_merges(&mut self, merges: impl IntoIterator) { + for row in &mut self.rows { + for cell in &mut row.cells { + cell.grid_span = 1; + cell.row_span = 1; + cell.h_merge = false; + cell.v_merge = false; + } + } + for m in merges { + if m.rs <= 1 && m.gs <= 1 { + continue; + } + for r in m.r..m.r.saturating_add(m.rs) { + for c in m.c..m.c.saturating_add(m.gs) { + if let Some(cell) = self.cell_mut(r, c) { + cell.h_merge = c > m.c; + cell.v_merge = r > m.r; + } + } + } + if let Some(cell) = self.cell_mut(m.r, m.c) { + cell.grid_span = u32::try_from(m.gs).unwrap_or(u32::MAX); + cell.row_span = u32::try_from(m.rs).unwrap_or(u32::MAX); + } + } + } + /// Swap the anchor cell's contents into the cell that takes over as anchor. + fn move_anchor(&mut self, from: (usize, usize), to: (usize, usize)) { + let Some(a) = self.cell(from.0, from.1).cloned() else { return }; + let Some(b) = self.cell(to.0, to.1).cloned() else { return }; + if let Some(x) = self.cell_mut(to.0, to.1) { + *x = a; + } + if let Some(x) = self.cell_mut(from.0, from.1) { + *x = b; + } + } /// Merge the rectangle (r0,c0)–(r1,c1) inclusive into its top-left cell. pub fn merge(&mut self, r0: usize, c0: usize, r1: usize, c1: usize) -> bool { let (r0, r1) = (r0.min(r1), r0.max(r1)); @@ -178,8 +259,9 @@ impl Table { if gs == 1 && rs == 1 { return false; } - for rr in r..r + rs { - for cc in c..c + gs { + // Spans come from the file and may reach past the table; only walk the cells that exist. + for rr in r..r.saturating_add(rs).min(self.rows.len()) { + for cc in c..c.saturating_add(gs).min(self.cols.len()) { if let Some(x) = self.cell_mut(rr, cc) { x.h_merge = false; x.v_merge = false; @@ -203,6 +285,58 @@ impl Table { } } +/// A merged area: anchor (r, c) spanning `rs` rows and `gs` columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Merge { + r: usize, + c: usize, + rs: usize, + gs: usize, +} + +impl Merge { + fn insert_row(self, at: usize) -> Self { + if at <= self.r { + Merge { r: self.r + 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs + 1, ..self } + } else { + self + } + } + fn insert_col(self, at: usize) -> Self { + if at <= self.c { + Merge { c: self.c + 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs + 1, ..self } + } else { + self + } + } + fn delete_row(self, at: usize) -> Option { + let m = if at < self.r { + Merge { r: self.r - 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs - 1, ..self } + } else { + self + }; + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) + } + fn delete_col(self, at: usize) -> Option { + let m = if at < self.c { + Merge { c: self.c - 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs - 1, ..self } + } else { + self + }; + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) + } +} + /// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in /// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and /// last rows and columns, corner cells) is filled, bordered and lettered. @@ -329,6 +463,113 @@ pub fn table_styles() -> Vec<(String, String)> { mod tests { use super::*; + /// Every cell is owned by exactly one anchor (itself or a span over it), and no span leaves the grid. + fn assert_topology(t: &Table) { + let (nr, nc) = (t.n_rows(), t.n_cols()); + let mut owner = vec![vec![0u32; nc]; nr]; + for (r, row) in t.rows.iter().enumerate() { + assert_eq!(row.cells.len(), nc); + for (c, cell) in row.cells.iter().enumerate() { + if cell.is_covered() { + assert_eq!((cell.grid_span, cell.row_span), (1, 1), "covered ({r},{c}) has spans"); + continue; + } + let (rs, gs) = (cell.row_span as usize, cell.grid_span as usize); + assert!(r + rs <= nr && c + gs <= nc, "span at ({r},{c}) leaves the grid"); + for o in owner.iter_mut().skip(r).take(rs) { + for x in o.iter_mut().skip(c).take(gs) { + *x += 1; + } + } + } + } + for (r, o) in owner.iter().enumerate() { + for (c, n) in o.iter().enumerate() { + assert_eq!(*n, 1, "cell ({r},{c}) owned {n} times"); + } + } + } + + fn merged() -> Table { + // 4x4 with a 2x2 merge at (1,1) holding "M". + let mut t = Table::new(4, 4, 400.0, 30.0); + if let Some(c) = t.cell_mut(1, 1) { + c.text = TextBody::from_text("M"); + } + assert!(t.merge(1, 1, 2, 2)); + assert_topology(&t); + t + } + + fn anchor_text(t: &Table, r: usize, c: usize) -> String { + t.cell(r, c).unwrap().text.text() + } + + #[test] + fn row_and_column_edits_keep_merges_consistent() { + for at in 0..=4 { + let mut t = merged(); + t.insert_row(at); + assert_topology(&t); + let mut t = merged(); + t.insert_col(at); + assert_topology(&t); + } + for at in 0..4 { + let mut t = merged(); + assert!(t.delete_row(at)); + assert_topology(&t); + let mut t = merged(); + assert!(t.delete_col(at)); + assert_topology(&t); + } + } + + #[test] + fn inserting_inside_a_merge_grows_it() { + let mut t = merged(); + t.insert_row(2); + let a = t.cell(1, 1).unwrap(); + assert_eq!((a.row_span, a.grid_span), (3, 2)); + assert!(t.cell(2, 2).unwrap().h_merge && t.cell(2, 2).unwrap().v_merge); + t.insert_col(0); + assert_eq!(t.cell(1, 2).unwrap().grid_span, 2); + assert_eq!(anchor_text(&t, 1, 2), "M"); + assert_topology(&t); + } + + #[test] + fn deleting_the_anchor_row_or_column_keeps_the_text() { + let mut t = merged(); + assert!(t.delete_row(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 2)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert!(t.delete_col(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 1)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert_topology(&t); + } + + #[test] + fn deleting_the_only_row_or_column_of_a_merge_removes_it() { + // A 1x3 merge across a row: deleting that row leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(1, 0, 1, 2)); + assert!(t.delete_row(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.grid_span <= 1)))); + assert_topology(&t); + // A 3x1 merge down a column: deleting that column leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(0, 1, 2, 1)); + assert!(t.delete_col(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.row_span <= 1)))); + assert_topology(&t); + } + #[test] fn insert_delete_merge_split() { let mut t = Table::new(3, 3, 300.0, 30.0); @@ -349,4 +590,15 @@ mod tests { assert!(!one.delete_row(0) && !one.delete_col(0)); assert!(table_styles().len() > 30); } + + #[test] + fn split_ignores_spans_past_the_table() { + let mut t = Table::new(2, 2, 100.0, 10.0); + if let Some(c) = t.cell_mut(0, 0) { + c.grid_span = u32::MAX; + c.row_span = u32::MAX; + } + assert!(t.split(0, 0)); + assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((1, 1))); + } } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2c3b4ced..da234d55 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -37,7 +37,7 @@ fn placeholder_inherits_position_and_styles() { fn body_levels_from_master() { let mut p = Presentation::default(); let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let s = defaults::new_slide(&mut p, lay); + let s = defaults::new_slide(&mut p, lay).expect("slide"); let ctx = Ctx::for_slide(&p, &s).unwrap(); let body = &s.shapes[1]; assert_eq!(body.ph_type(), Some(PhType::Obj)); @@ -97,16 +97,28 @@ fn serde_roundtrip() { assert!(q.slides.is_empty()); } +/// A deck whose ids are used up (a hostile file can arrive that way) yields no further ids: +/// allocating never repeats one (#407). +#[test] +fn exhausted_id_space_allocates_nothing() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + assert_eq!(p.alloc_id(), None); + assert_eq!(p.alloc_id(), None); + assert!(p.add_media("x.png", "image/png", vec![9]).is_none(), "fresh media bytes are refused too"); + let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap_or_default(); + assert!(defaults::new_slide(&mut p, lay).is_none(), "a new slide is refused"); +} + #[test] fn ids_are_unique_and_fix_next_id() { let mut p = Presentation::default(); - let a = p.alloc_id(); - let b = p.alloc_id(); + let a = p.alloc_id().expect("ids"); + let b = p.alloc_id().expect("ids"); assert!(b > a); p.next_id = 0; p.fix_next_id(); assert!(p.next_id >= b.min(p.next_id)); - let c = p.alloc_id(); + let c = p.alloc_id().expect("ids"); let mut all = vec![]; for s in &p.slides { all.push(s.id.0); @@ -165,7 +177,14 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { let ctx = Ctx::for_slide(&p, s).unwrap(); let title = &s.shapes[0]; let para = Paragraph::default(); - let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let link = Some(Hyperlink { + action: Action::Url { url: "https://example.org".into() }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); // The title's master style colours its text tx1; a link is hlink and underlined instead. assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)))); @@ -173,7 +192,14 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng"))); assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink)); // A slide link looks the same. - let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false }); + let slide = Some(Hyperlink { + action: Action::Slide { slide: s.id }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink); // The run's own colour and underline win; u="none" removes the underline. let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); @@ -231,3 +257,16 @@ fn sibling_lists_and_parents_reach_into_groups() { assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); } + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 6a1a7527..1d862b15 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -113,6 +113,8 @@ pub struct BodyProps { pub rtl_col: Option, /// Keep text upright when the shape rotates. pub upright: Option, + /// Space before the first paragraph and after the last count too (`spcFirstLastPara`). + pub space_first_last: Option, } impl BodyProps { @@ -135,6 +137,7 @@ impl BodyProps { } self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); + self.space_first_last = self.space_first_last.or(p.space_first_last); } } @@ -295,19 +298,38 @@ pub enum Strike { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum Action { - Url { url: String }, - Slide { slide: crate::SlideId }, + Url { + url: String, + }, + Slide { + slide: crate::SlideId, + }, NextSlide, PreviousSlide, FirstSlide, LastSlide, EndShow, LastViewed, - CustomShow { name: String }, - Program { path: String }, + /// Run a custom show. `returns` is PowerPoint's "Show and return". + CustomShow { + name: String, + #[serde(default = "custom_show_returns", skip_serializing_if = "is_true")] + returns: bool, + }, + Program { + path: String, + }, PlayMedia, } +fn custom_show_returns() -> bool { + true +} + +fn is_true(v: &bool) -> bool { + *v +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Hyperlink { @@ -316,6 +338,16 @@ pub struct Hyperlink { pub tooltip: String, #[serde(default)] pub highlight_click: bool, + /// Sound played when the action runs (`a:snd`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sound: Option, + /// `endSnd`: stop the sound that is already playing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, + /// Zoom (Insert ▸ Zoom) with Return to Zoom: in the show, after this slide is shown and + /// advanced past, go back to the slide with the zoom. Saved to .pptx as a plain slide link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zoom_return: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -345,9 +377,16 @@ pub struct RunProps { pub outline: Option, pub highlight: Option, pub link: Option, + /// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`). + pub hover: Option, pub lang: Option, pub shadow: Option, + /// `a:innerShdw` on the run. Outer shadow stays in `shadow`. + pub inner_shadow: Option, pub glow: Option, + pub reflection: Option, + /// Soft-edge radius in points (`a:softEdge`). + pub soft_edge: Option, /// Spelling/grammar errors are not flagged. pub no_proof: Option, } @@ -376,9 +415,13 @@ impl RunProps { outline, highlight, link, + hover, lang, shadow, + inner_shadow, glow, + reflection, + soft_edge, no_proof ); } @@ -448,6 +491,10 @@ pub struct Paragraph { #[serde(skip_serializing_if = "is_default_para")] pub props: ParaProps, pub runs: Vec, + /// `a:defRPr`: defaults for runs in this paragraph that omit a property. Sits above the + /// placeholder's list style, so a layout can still supply what this paragraph leaves unset. + #[serde(skip_serializing_if = "is_default_props")] + pub def_props: RunProps, /// Properties of the paragraph end mark (used for an empty paragraph's caret). #[serde(skip_serializing_if = "is_default_props")] pub end_props: RunProps, @@ -461,6 +508,21 @@ impl Paragraph { pub fn new(text: &str) -> Self { Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() } } + /// Leading tabs are the outline level (at most 8), the same rule as a typed indent. + /// Returns whether any tab was consumed. + pub fn promote_leading_tabs(&mut self) -> bool { + let Some(r) = self.runs.first_mut() else { return false }; + if r.kind != RunKind::Text { + return false; + } + let tabs = r.text.chars().take_while(|c| *c == '\t').count(); + if tabs == 0 { + return false; + } + r.text = r.text.trim_start_matches('\t').to_string(); + self.level = tabs.min(8) as u8; + true + } pub fn text(&self) -> String { self.runs.iter().map(|r| r.content()).collect() } diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 582f0728..870a8bb5 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -3,7 +3,7 @@ use deckcraft_color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; use serde::{Deserialize, Serialize}; -use crate::style::{ColorRef, Effects, Fill, Gradient, GradientShape, GradientStop, Line, Shadow}; +use crate::style::{ColorRef, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, Shadow}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -149,6 +149,31 @@ pub fn default_format() -> FormatScheme { } } +/// Design ▸ Variants: four colour variations of `t`, built from its built-in colours when it is +/// a built-in theme (so picking one variant after another doesn't drift), else from its own. +/// 1: as designed; 2 and 3: the accents rotated; 4: dark (light and dark colours swapped). +pub fn theme_variants(t: &Theme) -> Vec { + let base = builtin_themes().into_iter().find(|b| b.name == t.name).map_or_else(|| t.colors.clone(), |b| b.colors); + let rotate = |by: usize| { + let mut c = base.clone(); + let accents: Vec<_> = base.colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default(); + for (i, slot) in c.colors.iter_mut().skip(4).take(6).enumerate() { + if let Some(a) = accents.get((i + by) % accents.len().max(1)) { + *slot = *a; + } + } + c + }; + let mut dark = base.clone(); + dark.colors.swap(0, 1); + dark.colors.swap(2, 3); + let mut out = vec![base.clone(), rotate(1), rotate(3), dark]; + for (i, c) in out.iter_mut().enumerate().skip(1) { + c.name = format!("{} {}", base.name, i + 1); + } + out +} + /// Our original built-in themes (shown in Design ▸ Themes). Colours and names are our own. pub fn builtin_themes() -> Vec { let t = |name: &str, colors: ColorScheme, major: &str, minor: &str| Theme { @@ -268,3 +293,57 @@ pub fn builtin_font_schemes() -> Vec { f("Times / Arial", "Liberation Serif", "Liberation Sans"), ] } + +fn shadow(blur: f64, dist: f64, alpha: i32) -> Effects { + Effects { + outer_shadow: Some(Shadow { + color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(alpha)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "b".into(), + rotate_with_shape: false, + }), + ..Default::default() + } +} + +fn glow(radius: f64) -> Effects { + Effects { glow: Some(Glow { color: ph().with(ColorTransform::Alpha(60_000)), radius }), ..Default::default() } +} + +/// Named effect schemes for Design ▸ Effects. Each one replaces the theme's format scheme +/// (subtle, moderate and intense fills, lines and effects) and leaves the colour and font schemes. +pub fn builtin_format_schemes() -> Vec { + let mut soft = default_format(); + soft.name = "Soft".into(); + soft.lines = vec![Line::solid(ph(), 0.5), Line::solid(ph(), 0.75), Line::solid(ph(), 1.0)]; + soft.effects = vec![shadow(16.0, 1.0, 18_000), shadow(12.0, 2.0, 22_000), shadow(10.0, 3.0, 28_000)]; + + let mut crisp = default_format(); + crisp.name = "Crisp".into(); + crisp.fills = vec![Fill::solid(ph()), Fill::solid(ph()), Fill::solid(ph())]; + crisp.lines = vec![Line::solid(ph(), 1.0), Line::solid(ph(), 1.5), Line::solid(ph(), 2.25)]; + crisp.effects = vec![Effects::default(), Effects::default(), shadow(0.0, 2.0, 55_000)]; + + let mut lift = default_format(); + lift.name = "Lift".into(); + lift.effects = vec![Effects::default(), shadow(8.0, 6.0, 28_000), shadow(12.0, 10.0, 35_000)]; + + let mut glow_scheme = default_format(); + glow_scheme.name = "Glow".into(); + glow_scheme.effects = vec![Effects::default(), glow(6.0), glow(12.0)]; + + let mut plain = default_format(); + plain.name = "Plain".into(); + plain.fills = vec![Fill::solid(ph()), Fill::solid(ph()), Fill::solid(ph())]; + plain.lines = vec![Line::solid(ph(), 0.75), Line::solid(ph(), 0.75), Line::solid(ph(), 0.75)]; + plain.effects = vec![Effects::default(), Effects::default(), Effects::default()]; + + vec![default_format(), soft, crisp, lift, glow_scheme, plain] +} diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs new file mode 100644 index 00000000..f893a764 --- /dev/null +++ b/crates/model/src/trendline.rs @@ -0,0 +1,623 @@ +//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a +//! moving average. The fit is what the chart drawer plots and what the axis grows to include. + +use crate::chart::{ChartType, Series, Trendline, TrendlineType}; + +/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x, +/// an exponential that overflowed). +#[derive(Clone, Debug)] +pub struct TrendFit { + kind: TrendlineType, + model: Model, + r2: f64, + x_min: f64, + x_max: f64, +} + +#[derive(Clone, Debug)] +enum Model { + /// `y = c[0] + c[1] x + c[2] x² + …` + Poly(Vec), + /// `y = c · e^(b x)` + Exp { c: f64, b: f64 }, + /// `y = b + c · ln(x)` + Log { b: f64, c: f64 }, + /// `y = c · x^b` + Power { c: f64, b: f64 }, + /// Window averages in x order. No equation. + Moving(Vec<(f64, f64)>), +} + +impl Trendline { + /// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more + /// points than it was given, or the normal equations have no solution. + pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option { + let mut pairs: Vec<(f64, f64)> = + xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + if pairs.len() < 2 { + return None; + } + pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?); + let intercept = self.intercept.filter(|v| v.is_finite()); + let (model, used_y, yhat) = match self.kind { + TrendlineType::Linear => { + let (xs, ys) = split(&pairs); + let (b, m) = linreg(&xs, &ys, intercept)?; + let yhat: Vec = xs.iter().map(|x| b + m * x).collect(); + (Model::Poly(vec![b, m]), ys, yhat) + } + TrendlineType::Poly => { + let order = self.order.unwrap_or(2).clamp(2, 6) as usize; + if pairs.len() < order + 1 { + return None; + } + let (xs, ys) = split(&pairs); + let coeff = polyfit(&xs, &ys, order, intercept)?; + let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); + (Model::Poly(coeff), ys, yhat) + } + TrendlineType::Exp => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + (Model::Exp { c, b }, ys, yhat) + } + TrendlineType::Log => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ys): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip(); + let (b, c) = linreg(&ln_x, &ys, intercept)?; + let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); + (Model::Log { b, c }, ys, yhat) + } + TrendlineType::Power => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + (Model::Power { c, b }, ys, yhat) + } + TrendlineType::MovingAvg => { + let period = self.period.unwrap_or(2).clamp(2, 255) as usize; + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + (Model::Moving(pts), ys, yhat) + } + }; + if yhat.iter().any(|v| !v.is_finite()) { + return None; + } + // A moving average starts at the first full window, not at the first data point. + let (x_min, x_max) = if let Model::Moving(pts) = &model { + match (pts.first(), pts.last()) { + (Some(a), Some(b)) => (a.0, b.0), + _ => (x_min, x_max), + } + } else { + (x_min, x_max) + }; + Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) + } + + /// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving + /// average has no equation, and PowerPoint does not show its R², so that label is left off + /// even when the file asks for it. + pub fn caption(&self, fit: &TrendFit) -> String { + let mut lines = Vec::new(); + if self.disp_eq + && let Some(eq) = fit.equation() + { + lines.push(eq); + } + if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg { + lines.push(fit.r2_label()); + } + lines.join("\n") + } +} + +impl TrendFit { + pub fn y(&self, x: f64) -> Option { + if !x.is_finite() { + return None; + } + let y = match &self.model { + Model::Poly(c) => poly_y(c, x), + Model::Exp { c, b } => c * (b * x).exp(), + Model::Log { b, c } => { + if x <= 0.0 { + return None; + } + b + c * x.ln() + } + Model::Power { c, b } => { + if x <= 0.0 { + return None; + } + c * x.powf(*b) + } + Model::Moving(_) => return None, + }; + y.is_finite().then_some(y) + } + + /// Samples from `x0` to `x1`, forecast included. A moving average returns its window points + /// and ignores the forecast (Excel does not extend one). + pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> { + let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + if !x0.is_finite() || !x1.is_finite() { + return Vec::new(); + } + if let Model::Moving(pts) = &self.model { + return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + } + if matches!(self.kind, TrendlineType::Linear) { + return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect(); + } + let steps = 48; + (0..=steps) + .filter_map(|i| { + let x = x0 + (x1 - x0) * i as f64 / steps as f64; + self.y(x).map(|y| (x, y)) + }) + .collect() + } + + /// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile + /// value cannot walk off the chart. + pub fn span(&self, forward: Option, backward: Option) -> (f64, f64) { + let pad = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0); + if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) } + } + + pub fn equation(&self) -> Option { + let body = match &self.model { + Model::Poly(c) => poly_equation(c), + Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))), + Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)), + Model::Power { c, b } => { + let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) }; + let sign = if *c < 0.0 { "-" } else { "" }; + Some(format!("y = {sign}{mag}{}", x_pow(*b))) + } + Model::Moving(_) => None, + }?; + Some(body) + } + + pub fn r2_label(&self) -> String { + let r = if self.r2.is_finite() { self.r2 } else { 0.0 }; + if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") } + } +} + +impl Series { + /// Trendline y values, including a short forecast, that sit near this series. The axis uses + /// them so a forecast is not clipped off the plot. A value many times the series' own range + /// is left out, so a wild polynomial cannot flatten the chart. + pub fn trendline_ys(&self, scatter: bool) -> Vec { + let n = self.values.len().min(10_000); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..n { + let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y))); + if !data_lo.is_finite() || !data_hi.is_finite() { + return Vec::new(); + } + let span = (data_hi - data_lo).abs().max(1.0); + let mid = (data_lo + data_hi) / 2.0; + let mut out = Vec::new(); + for t in self.trendlines.iter().take(8) { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + for (_, y) in fit.samples(x0, x1) { + if y.is_finite() && (y - mid).abs() <= span * 8.0 { + out.push(y); + } + } + } + out + } +} + +/// Whether a series is plotted against numeric x values rather than category positions. +pub fn series_is_scatter(kind: ChartType) -> bool { + matches!(kind, ChartType::Scatter | ChartType::Bubble) +} + +fn split(pairs: &[(f64, f64)]) -> (Vec, Vec) { + pairs.iter().map(|(x, y)| (*x, *y)).unzip() +} + +/// `y = b + m x`. `intercept` fixes `b`. +fn linreg(xs: &[f64], ys: &[f64], intercept: Option) -> Option<(f64, f64)> { + if xs.len() != ys.len() || xs.len() < 2 { + return None; + } + if let Some(b) = intercept { + let mut sxx = 0.0; + let mut sxy = 0.0; + for (x, y) in xs.iter().zip(ys) { + sxx += x * x; + sxy += x * (y - b); + } + if sxx.abs() < 1e-18 { + return None; + } + let m = sxy / sxx; + return (m.is_finite() && b.is_finite()).then_some((b, m)); + } + let n = xs.len() as f64; + let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0); + for (x, y) in xs.iter().zip(ys) { + sx += x; + sy += y; + sxx += x * x; + sxy += x * y; + } + let den = n * sxx - sx * sx; + if den.abs() < 1e-18 { + return None; + } + let m = (n * sxy - sx * sy) / den; + let b = (sy - m * sx) / n; + (m.is_finite() && b.is_finite()).then_some((b, m)) +} + +fn poly_y(coeff: &[f64], x: f64) -> f64 { + let mut acc = 0.0; + let mut xp = 1.0; + for c in coeff { + acc += c * xp; + if !acc.is_finite() { + return f64::NAN; + } + xp *= x; + } + acc +} + +/// Polynomial through the normal equations. `x` is centred when the intercept is free, then +/// expanded back to powers of `x`, so order 6 on category numbers stays solvable. +#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations +fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Option> { + let n = xs.len(); + if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 { + return None; + } + if let Some(b0) = intercept { + let m = order; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (x, y) in xs.iter().zip(ys) { + let dy = y - b0; + for i in 0..m { + let xi = x.powi((i + 1) as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * x.powi((j + 1) as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let solved = solve(ata, atb)?; + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(solved); + return coeff.iter().all(|v| v.is_finite()).then_some(coeff); + } + let xm = xs.iter().sum::() / n as f64; + let us: Vec = xs.iter().map(|x| x - xm).collect(); + let m = order + 1; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (u, y) in us.iter().zip(ys) { + for i in 0..m { + let ui = if i == 0 { 1.0 } else { u.powi(i as i32) }; + atb[i] += y * ui; + for j in i..m { + let uj = if j == 0 { 1.0 } else { u.powi(j as i32) }; + ata[i][j] += ui * uj; + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let centred = solve(ata, atb)?; + let coeff = expand_shift(¢red, xm); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`. +#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift +fn expand_shift(a: &[f64], xm: f64) -> Vec { + let mut c = vec![0.0; a.len()]; + for (k, ak) in a.iter().enumerate() { + let mut binom = 1.0; + for j in 0..=k { + c[j] += ak * binom * (-xm).powi((k - j) as i32); + if j < k { + binom *= (k - j) as f64 / (j + 1) as f64; + } + } + } + c +} + +#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + if n == 0 || n > 8 || a.len() != n { + return None; + } + for col in 0..n { + let mut piv = col; + for r in col + 1..n { + if a[r][col].abs() > a[piv][col].abs() { + piv = r; + } + } + if a[piv][col].abs() < 1e-12 { + return None; + } + a.swap(col, piv); + b.swap(col, piv); + let div = a[col][col]; + for c in col..n { + a[col][c] /= div; + } + b[col] /= div; + for r in 0..n { + if r == col { + continue; + } + let f = a[r][col]; + if f == 0.0 { + continue; + } + for c in col..n { + a[r][c] -= f * a[col][c]; + } + b[r] -= f * b[col]; + } + } + b.iter().all(|v| v.is_finite()).then_some(b) +} + +fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 { + if ys.is_empty() || ys.len() != yhat.len() { + return 0.0; + } + let mean = ys.iter().sum::() / ys.len() as f64; + let mut ss_tot = 0.0; + let mut ss_res = 0.0; + for (y, h) in ys.iter().zip(yhat) { + ss_tot += (y - mean) * (y - mean); + ss_res += (y - h) * (y - h); + } + if ss_tot < 1e-18 { + return if ss_res < 1e-12 { 1.0 } else { 0.0 }; + } + let r = 1.0 - ss_res / ss_tot; + if r.is_finite() { r } else { 0.0 } +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + let a = v.abs(); + if a == 0.0 { + return "0".to_string(); + } + if a >= 1.0e6 || a < 1.0e-3 { + return format!("{v:.2e}").replace('+', ""); + } + let digits = if a >= 100.0 { + 1 + } else if a >= 10.0 { + 2 + } else { + 3 + }; + let mut s = format!("{v:.prec$}", prec = digits); + if let Some(dot) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.len() == dot + 1 { + s.pop(); + } + } + s +} + +fn x_pow(p: f64) -> String { + if (p - 1.0).abs() < 1e-6 { + "x".to_string() + } else if (p - 2.0).abs() < 1e-6 { + "x²".to_string() + } else if (p - 3.0).abs() < 1e-6 { + "x³".to_string() + } else { + format!("x^{}", num(p)) + } +} + +fn poly_equation(coeff: &[f64]) -> Option { + let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max).max(1.0); + let mut body = String::new(); + let mut first = true; + for (k, c) in coeff.iter().enumerate().rev() { + if c.abs() < scale * 1e-8 { + continue; + } + let pow = match k { + 0 => String::new(), + 1 => "x".to_string(), + _ => x_pow(k as f64), + }; + let mag = if pow.is_empty() { + num(c.abs()) + } else if (c.abs() - 1.0).abs() < scale * 1e-8 { + pow + } else { + format!("{}{}", num(c.abs()), pow) + }; + if first { + if *c < 0.0 { + body.push('-'); + } + body.push_str(&mag); + first = false; + } else if *c < 0.0 { + body.push_str(" - "); + body.push_str(&mag); + } else { + body.push_str(" + "); + body.push_str(&mag); + } + } + if body.is_empty() { + body.push('0'); + } + Some(format!("y = {body}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(kind: TrendlineType) -> Trendline { + Trendline { kind, ..Default::default() } + } + + #[test] + fn linear_recovers_slope_and_a_fixed_intercept() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let ys = [3.0, 5.0, 7.0, 9.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit"); + assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9); + assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9); + assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1")); + assert_eq!(fit.r2_label(), "R² = 1"); + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit"); + assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6); + assert_eq!(fit.equation().as_deref(), Some("y = 2x")); + } + + #[test] + fn exp_log_power_and_quadratic_round_their_points() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp"); + for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) { + assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}"); + } + assert!(exp.equation().unwrap().contains("e^(")); + let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log"); + assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6); + assert!(log.equation().unwrap().contains("ln(x)")); + let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power"); + assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(power.equation().as_deref(), Some("y = x²")); + let mut poly = line(TrendlineType::Poly); + poly.order = Some(2); + let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly"); + assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(quad.equation().as_deref(), Some("y = x²")); + } + + #[test] + fn moving_average_and_forecast_span() { + let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() } + .fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]) + .expect("ma"); + let pts = fit.samples(0.0, 10.0); + assert_eq!(pts.len(), 3); + assert!((pts[0].1 - 5.0).abs() < 1e-9); + assert!((pts[1].1 - 6.0).abs() < 1e-9); + assert!((pts[2].1 - 7.0).abs() < 1e-9); + assert!(fit.equation().is_none()); + let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line"); + assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5)); + assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); + } + + #[test] + fn moving_average_shows_neither_equation_nor_r2() { + let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() }; + let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma"); + assert!(fit.r2_label().starts_with("R² =")); + assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit)); + } + + #[test] + fn too_few_points_and_non_finite_input_fit_nothing() { + assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); + assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none()); + assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none()); + assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none()); + let mut t = line(TrendlineType::Linear); + t.disp_eq = true; + t.disp_r_sqr = true; + let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit"); + assert_eq!(t.caption(&fit), "y = x\nR² = 1"); + } +} diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index 6100fd4c..7342574e 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -414,9 +414,20 @@ pub struct OpusTiming { pub total: i64, } +fn timing_add(a: i64, b: i64) -> Result { + a.checked_add(b).ok_or_else(|| Error::Invalid("opus timing overflow".into())) +} + +fn timing_sub(a: i64, b: i64) -> Result { + a.checked_sub(b).ok_or_else(|| Error::Invalid("opus timing overflow".into())) +} + impl OpusTiming { /// Timing of the Opus stream `s` (`pre_skip` from its `OpusHead`). - pub fn of(stream: &Stream, pre_skip: u32) -> OpusTiming { + /// + /// A granule or a run of packet durations that does not fit in `i64` is an error. Ordinary + /// files, including a truncated tail and end trimming, are unchanged. + pub fn of(stream: &Stream, pre_skip: u32) -> Result { let durations: Vec = stream.packets.iter().map(|p| opus_packet_samples(p.prefix, p.size).unwrap_or(0)).collect(); let n = durations.len(); let mut ends: Vec> = vec![None; n]; @@ -433,14 +444,17 @@ impl OpusTiming { // trailing packets without a granule (truncated file): continue from the cursor let mut c = cursor.unwrap_or(0); for k in i..n { - c += durations[k] as i64; + c = timing_add(c, i64::from(durations[k]))?; ends[k] = Some(c); } break; } let Some(g) = stream.packets[j].granule else { break }; - let sum: i64 = durations[i..=j].iter().map(|&d| d as i64).sum(); - let forward = cursor.map(|c| c + sum); + let sum = durations[i..=j].iter().try_fold(0i64, |a, &d| timing_add(a, i64::from(d)))?; + let forward = match cursor { + Some(c) => Some(timing_add(c, sum)?), + None => None, + }; let end = match forward { // end trimming on the last page (RFC 7845 §4.5): keep the forward positions Some(f) if stream.packets[j].eos && g < f => { @@ -454,18 +468,24 @@ impl OpusTiming { let mut e = end; for k in (i..=j).rev() { ends[k] = Some(e); - e -= durations[k] as i64; + e = timing_sub(e, i64::from(durations[k]))?; } cursor = Some(end); i = j + 1; } - let raw_starts: Vec = ends.iter().zip(&durations).map(|(e, &d)| e.unwrap_or(0) - d as i64).collect(); + let mut raw_starts = Vec::with_capacity(n); + for (e, &d) in ends.iter().zip(&durations) { + raw_starts.push(timing_sub(e.unwrap_or(0), i64::from(d))?); + } let base = raw_starts.first().copied().unwrap_or(0).max(0); - let shift = base + pre_skip as i64; - let starts: Vec = raw_starts.iter().map(|s| s - shift).collect(); + let shift = timing_add(base, i64::from(pre_skip))?; + let mut starts = Vec::with_capacity(n); + for s in &raw_starts { + starts.push(timing_sub(*s, shift)?); + } let end_all = trimmed_end.or(cursor).unwrap_or(0); - let total = (end_all - shift).max(0); - OpusTiming { pre_skip, starts, durations, total } + let total = timing_sub(end_all, shift)?.max(0); + Ok(OpusTiming { pre_skip, starts, durations, total }) } } diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index f1879b36..8e118b4e 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -99,7 +99,7 @@ fn opus_pages_packets_and_timing() { assert_eq!(st.packets.len(), 10); assert!(st.saw_eos); assert_eq!(o.read_packet(&f, s, 4).unwrap(), pkt(4, 44)); - let t = OpusTiming::of(st, 312); + let t = OpusTiming::of(st, 312).unwrap(); assert_eq!(t.durations, vec![960; 10]); assert_eq!(t.starts[0], -312); assert_eq!(t.starts[9], 9 * 960 - 312); @@ -129,7 +129,7 @@ fn packets_spanning_pages() { assert_eq!(st.packets[0].parts.len(), 2); assert_eq!(o.read_packet(&f, 0, 0).unwrap(), big); assert_eq!(o.read_packet(&f, 0, 1).unwrap(), small); - let t = OpusTiming::of(st, 0); + let t = OpusTiming::of(st, 0).unwrap(); assert_eq!(t.starts, vec![0, 960]); assert_eq!(t.total, 1920); } @@ -144,13 +144,13 @@ fn corrupt_pages_are_skipped_and_truncation_is_safe() { let o = open(&g).unwrap(); assert!(o.warnings.iter().any(|w| w.contains("CRC")), "{:?}", o.warnings); assert_eq!(o.streams[0].packets.len(), 9); - let t = OpusTiming::of(&o.streams[0], 312); + let t = OpusTiming::of(&o.streams[0], 312).unwrap(); // the granules after the gap put the later packets at their true positions assert_eq!(*t.starts.last().unwrap(), 11 * 960 - 312); for cut in 0..f.len() { if let Ok(o) = open(&f[..cut].to_vec()) { for st in &o.streams { - let t = OpusTiming::of(st, 312); + let t = OpusTiming::of(st, 312).unwrap(); assert_eq!(t.starts.len(), st.packets.len()); } } @@ -179,3 +179,20 @@ fn opus_toc_durations() { assert_eq!(opus_packet_samples([0xF8 | 3, 7], 10), None); // 140 ms: invalid assert_eq!(opus_packet_samples([0xF8, 0], 0), None); } + +#[test] +fn opus_timing_rejects_a_granule_that_overflows() { + let packet = |granule: i64| Stream { + serial: 1, + codec: Codec::Opus, + headers: vec![], + packets: vec![Packet { parts: vec![], size: 10, granule: Some(granule), page: 0, eos: true, prefix: [0x00, 0] }], + pages: 1, + last_granule: Some(granule), + saw_eos: true, + }; + let ok = OpusTiming::of(&packet(480), 0).unwrap(); + assert_eq!(ok.starts, vec![0]); + assert_eq!(ok.total, 480); + assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err()); +} diff --git a/crates/opus/src/header.rs b/crates/opus/src/header.rs index 2fe0c752..90423aec 100644 --- a/crates/opus/src/header.rs +++ b/crates/opus/src/header.rs @@ -123,6 +123,23 @@ impl OpusHead { Ok(OpusHead { channels, pre_skip, input_sample_rate, output_gain, mapping_family: family, streams, coupled_streams: coupled, mapping }) } + /// Checks the invariants the decoder indexes by, for headers built by hand rather than parsed. + pub fn validate(&self) -> Result<()> { + if self.channels == 0 { + return Err(Error::InvalidHeader("zero channels")); + } + if self.streams == 0 || self.coupled_streams > self.streams || self.streams + self.coupled_streams > 255 { + return Err(Error::InvalidHeader("bad stream counts")); + } + if self.mapping.len() != self.channels { + return Err(Error::InvalidHeader("channel mapping length differs from channel count")); + } + if self.mapping.iter().any(|&m| m != 255 && m as usize >= self.streams + self.coupled_streams) { + return Err(Error::InvalidHeader("channel mapping out of range")); + } + Ok(()) + } + /// Linear output gain factor. pub fn gain_linear(&self) -> f32 { 10f32.powf(self.output_gain as f32 / (20.0 * 256.0)) diff --git a/crates/opus/src/multistream.rs b/crates/opus/src/multistream.rs index a41ba10c..e3042ca0 100644 --- a/crates/opus/src/multistream.rs +++ b/crates/opus/src/multistream.rs @@ -32,6 +32,7 @@ impl Decoder { /// Creates a decoder for a parsed header at `sample_rate` (8/12/16/24/48 kHz). pub fn from_head(head: OpusHead, sample_rate: u32) -> Result { + head.validate()?; let mut streams = Vec::with_capacity(head.streams); for s in 0..head.streams { let ch = if s < head.coupled_streams { 2 } else { 1 }; @@ -169,3 +170,29 @@ fn reframe(p: &Packet) -> Vec { } v } + +#[cfg(test)] +mod tests { + use super::*; + + fn head(channels: usize, streams: usize, coupled: usize, mapping: Vec) -> OpusHead { + OpusHead { channels, pre_skip: 0, input_sample_rate: 48000, output_gain: 0, mapping_family: 1, streams, coupled_streams: coupled, mapping } + } + + #[test] + fn from_head_rejects_inconsistent_headers() { + assert!(Decoder::from_head(head(1, 0, 0, vec![0]), 48000).is_err(), "zero streams"); + assert!(Decoder::from_head(head(2, 1, 0, vec![0]), 48000).is_err(), "short mapping"); + assert!(Decoder::from_head(head(1, 1, 0, vec![1]), 48000).is_err(), "mapping index out of range"); + assert!(Decoder::from_head(head(1, 1, 2, vec![0]), 48000).is_err(), "more coupled than streams"); + assert!(Decoder::from_head(head(0, 1, 0, vec![]), 48000).is_err(), "zero channels"); + } + + #[test] + fn from_head_accepts_valid_headers() { + assert!(Decoder::from_head(head(1, 1, 0, vec![0]), 48000).is_ok()); + assert!(Decoder::from_head(head(2, 1, 1, vec![0, 1]), 48000).is_ok()); + assert!(Decoder::from_head(head(3, 2, 1, vec![0, 1, 2]), 48000).is_ok()); + assert!(Decoder::from_head(head(2, 1, 0, vec![0, 255]), 48000).is_ok()); + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 11cf931c..03a373ac 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,3 +24,9 @@ hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" + +# Off the web build, krilla compresses each slide's artwork on a background thread while the next +# slide renders (the same bytes, in parallel). The root patch selects the same pinned +# backend as craft-pdf; this dependency only enables its native compression feature. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +krilla = { version = "=0.8.2", features = ["rayon"] } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 753a212b..3faec84d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -77,7 +77,7 @@ pub struct PdfOptions { pub frame_slides: bool, /// Document title (defaults to the presentation title). pub title: Option, - /// Handout/notes page size in points (default US Letter portrait). + /// Handout/notes page size in points (default US Letter portrait; see [`paper_size`]). pub paper: (f64, f64), } @@ -96,6 +96,36 @@ impl Default for PdfOptions { } } +/// Paper sizes the Print page offers, as (id, label, width × height in points, portrait). +pub const PAPERS: [(&str, &str, (f64, f64)); 4] = [ + ("letter", "Letter (8.5 × 11 in)", (612.0, 792.0)), + ("a4", "A4 (210 × 297 mm)", (595.28, 841.89)), + ("legal", "Legal (8.5 × 14 in)", (612.0, 1008.0)), + ("a3", "A3 (297 × 420 mm)", (841.89, 1190.55)), +]; + +/// The page size in points for a paper id from [`PAPERS`] (case-insensitive), turned on its side +/// when `landscape`. `None` for an unknown id. +pub fn paper_size(id: &str, landscape: bool) -> Option<(f64, f64)> { + let (_, _, (w, h)) = PAPERS.iter().find(|p| p.0.eq_ignore_ascii_case(id.trim()))?; + Some(if landscape { (*h, *w) } else { (*w, *h) }) +} + +/// The slide box on a notes page of `paper`: an inch of margin at the sides and the slide at most +/// half the page tall, so landscape paper still leaves room for the notes. +pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect { + let (pw, ph) = paper; + let mut w = (pw - 2.0 * 72.0).max(1.0); + let mut h = w / aspect.max(0.01); + let max_h = (ph * 0.5 - 54.0).max(1.0); + if h > max_h { + h = max_h; + w = h * aspect; + } + let x = (pw - w) / 2.0; + Rect::new(x, 54.0, x + w, 54.0 + h) +} + /// Export `pres` as PDF bytes. pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { write::guard(|| export_inner(pres, opts)) @@ -126,10 +156,8 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let pages: Vec> = match opts.layout { PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), PageLayout::Notes => { - let (pw, _) = opts.paper; - let w = pw - 2.0 * 72.0; - let h = w * sh / sw; - slides.iter().map(|&i| vec![(i, Rect::new(72.0, 54.0, 72.0 + w, 54.0 + h))]).collect() + let b = notes_frames(pres, opts.paper).0; + slides.iter().map(|&i| vec![(i, b)]).collect() } PageLayout::Handouts { per_page } => { let boxes = handout_boxes(per_page, opts.paper, sw / sh); @@ -165,12 +193,11 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { } match opts.layout { PageLayout::Notes => { - if let Some((i, b)) = v.first() { + if let Some((i, _)) = v.first() { let notes = pres.slides[*i].notes_text(); - let area = Rect::new(72.0, b.y1 + 36.0, pw - 72.0, ph - 54.0); - ex.paragraphs(&mut s, ¬es, area, 12.0)?; + ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0)?; } - ex.footer(&mut s, pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?; + ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?; } PageLayout::Handouts { per_page } => { if per_page == 3 { @@ -178,7 +205,7 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1)); } } - ex.footer(&mut s, pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?; + ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?; } PageLayout::Slides => {} } @@ -192,6 +219,136 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { writer.finish().map_err(Into::into) } +/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points. +fn master_rect(pres: &Presentation, m: &deckcraft_model::Master, kind: deckcraft_model::PhType, paper: (f64, f64)) -> Option { + let x = m.shapes.iter().find(|s| s.ph_type() == Some(kind))?.xfrm?; + let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); + let r = Rect::new(x.x * kx, x.y * ky, (x.x + x.w) * kx, (x.y + x.h) * ky); + (r.width() > 1.0 && r.height() > 1.0).then_some(r) +} + +/// A notes page's slide box and notes area on `paper`: where the notes master puts its slide image +/// and body placeholders, or the default layout when it has none. +pub fn notes_frames(pres: &Presentation, paper: (f64, f64)) -> (Rect, Rect) { + let (pw, ph) = paper; + let aspect = pres.slide_size.width.max(1.0) / pres.slide_size.height.max(1.0); + let nm = pres.notes_master.as_deref(); + let slide = match nm.and_then(|m| master_rect(pres, m, deckcraft_model::PhType::SlideImage, paper)) { + // The slide keeps its own shape, centred in the frame. + Some(f) => { + let (w, h) = if f.width() / f.height() > aspect { (f.height() * aspect, f.height()) } else { (f.width(), f.width() / aspect) }; + Rect::from_center_size(f.center(), (w, h)) + } + None => notes_slide_box(paper, aspect), + }; + let notes = nm + .and_then(|m| master_rect(pres, m, deckcraft_model::PhType::Body, paper)) + .unwrap_or_else(|| Rect::new(72.0, slide.y1 + 36.0, pw - 72.0, ph - 54.0)); + (slide, notes) +} + +/// How a master's header, date, footer or page number is printed: its own resolved formatting. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + pub family: String, + /// Points, before scaling to the paper. + pub size: f64, + pub bold: bool, + pub italic: bool, + pub color: deckcraft_model::Rgba, +} + +/// The resolved style of placeholder `sh`'s first run (or its paragraph's end properties) on +/// master `m`: run, list style, master text styles, theme fonts and colours. +pub fn master_text_style(pres: &Presentation, m: &deckcraft_model::Master, sh: &deckcraft_model::Shape) -> TextStyle { + use deckcraft_model::resolve::{self, Ctx}; + let ctx = Ctx::for_master(pres, m); + let para = sh.text.as_ref().and_then(|t| t.paragraphs.first()).cloned().unwrap_or_default(); + let own = para.runs.first().map(|r| r.props.clone()).unwrap_or_else(|| para.end_props.clone()); + let r = resolve::run(&ctx, sh, ¶, &own); + let family = r.font.as_deref().map(|f| if f.starts_with('+') { m.theme.font(f) } else { f.to_string() }).unwrap_or_default(); + let color = match &r.fill { + Some(deckcraft_model::Fill::Solid { color }) => ctx.color(color, None), + _ => deckcraft_model::Rgba::rgb(0x60, 0x60, 0x60), + }; + TextStyle { family, size: r.size.unwrap_or(12.0).clamp(1.0, 400.0), bold: r.bold == Some(true), italic: r.italic == Some(true), color } +} + +/// One laid-out piece of master text on a page of `paper` points: a run's text on one line, its +/// baseline start and its size, colour, weight and slant. +#[derive(Clone, Debug, PartialEq)] +pub struct TextPiece { + pub at: (f64, f64), + pub text: String, + pub size: f64, + pub color: deckcraft_model::Rgba, + pub bold: bool, + pub italic: bool, +} + +/// The header, date, footer and page number of `m` for page `n`, laid out by the text engine +/// (wrapped, mixed formatting) and scaled to `paper`, for drawing without glyph access (the +/// Notes Page view). +pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, paper: (f64, f64), n: usize) -> Vec { + let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); + let mut out = vec![]; + for pt in deckcraft_render::place_master_texts(pres, m, n) { + for run in &pt.layout.runs { + let Some(g) = run.glyphs.first() else { continue }; + let text: String = pt + .body + .paragraphs + .get(run.para) + .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .unwrap_or_default() + .chars() + .skip(run.chars.0) + .take(run.chars.1.saturating_sub(run.chars.0)) + .collect(); + if text.trim().is_empty() { + continue; + } + let p = pt.transform * kurbo::Point::new(g.1, g.2); + out.push(TextPiece { + at: (p.x * kx, p.y * ky), + text, + size: run.size * ky, + color: run.color, + bold: run.fake_bold || run.face.weight >= 600.0, + italic: run.fake_italic || run.face.italic, + }); + } + } + out +} + +/// The header, date, footer and page-number text a master puts on page `n`, with where it goes, +/// whether it's right-aligned and its style. Placeholders with no text are left out; the page +/// number is always `n`. +pub fn master_texts(pres: &Presentation, m: &deckcraft_model::Master, paper: (f64, f64), n: usize) -> Vec<(Rect, String, bool, TextStyle)> { + use deckcraft_model::PhType; + let mut out = vec![]; + for kind in [PhType::Header, PhType::Date, PhType::Footer, PhType::SlideNum] { + let Some(r) = master_rect(pres, m, kind, paper) else { continue }; + let Some(sh) = m.shapes.iter().find(|s| s.ph_type() == Some(kind)) else { continue }; + let text = if kind == PhType::SlideNum { n.to_string() } else { sh.text.as_ref().map(|t| t.text()).unwrap_or_default().trim().to_string() }; + if text.is_empty() { + continue; + } + let right = sh + .text + .as_ref() + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align) + .map_or(matches!(kind, PhType::Date | PhType::SlideNum), |a| a == deckcraft_model::text::Align::Right); + let mut style = master_text_style(pres, m, sh); + // Sizes scale with the page, as the placeholders do. + style.size *= paper.1 / pres.notes_size.height.max(1.0); + out.push((r, text, right, style)); + } + out +} + /// Slide boxes for a handout page (portrait paper, half-inch-ish margins). pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec { let (pw, ph) = paper; @@ -260,7 +417,9 @@ impl Exporter<'_> { let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; let img = deckcraft_render::render_slide(pres, i, &RenderOpts { scale, threads: 4, ..Default::default() }); if img.width > 0 && img.height > 0 { - let mut rgba = img.pixels.clone(); + let (w, h) = (img.width, img.height); + // Taken, not copied: a print-resolution slide is ~16 MB. + let mut rgba = img.pixels; for px in rgba.as_chunks_mut::<4>().0 { // Composite onto white (slides are normally opaque). let a = px[3] as u32; @@ -269,7 +428,7 @@ impl Exporter<'_> { } px[3] = 255; } - let image = Image::from_rgba8(rgba, img.width, img.height); + let image = Image::from_rgba8(rgba, w, h); if let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); s.draw_image(image, size); @@ -292,7 +451,7 @@ impl Exporter<'_> { if self.opts.text_layer { let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); for pt in deckcraft_render::place_slide(pres, i).texts { - self.text_layer(s, &pt, view)?; + self.text_layer(s, &pt, view, false)?; } let _ = sh; } @@ -300,7 +459,7 @@ impl Exporter<'_> { } /// Invisible glyphs exactly over the rasterised text. - fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine) -> Result<()> { + fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> { for run in &pt.layout.runs { if run.glyphs.is_empty() || run.size <= 0.0 { continue; @@ -320,8 +479,7 @@ impl Exporter<'_> { } let font = self.font(&run.face)?; let n = run.glyphs.len(); - let char_starts: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); - let one_to_one = char_starts.len() - 1 == n; + let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n); let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2); let kg: Vec = run .glyphs @@ -329,18 +487,21 @@ impl Exporter<'_> { .enumerate() .map(|(gi, g)| { let next = run.glyphs.get(gi + 1).map_or(g.1 + run.face.advance(g.0) * run.size / run.face.upem.max(1.0), |nx| nx.1); - let range = if one_to_one { - char_starts[gi]..char_starts[gi + 1] - } else if gi == 0 { - 0..text.len() - } else { - text.len()..text.len() - }; + let range = ranges.get(gi).cloned().unwrap_or(text.len()..text.len()); KrillaGlyph::new(GlyphId::new(g.0), ((next - g.1) / run.size) as f32, 0.0, ((g.2 - y0) / run.size) as f32, 0.0, range, None) }) .collect(); s.push_transform(&tf(view * pt.transform * Affine::translate((x0, y0)))); - s.set_fill(Some(grey(0, 0.0))); + s.set_fill(Some(if visible { + let c = run.color; + Fill { + paint: krilla::color::rgb::Color::new(c.r, c.g, c.b).into(), + opacity: NormalizedF32::new((f64::from(c.a) / 255.0 * run.alpha) as f32).unwrap_or(NormalizedF32::ONE), + ..Default::default() + } + } else { + grey(0, 0.0) + })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, run.size as f32, false); s.set_fill(None); s.pop(); @@ -437,6 +598,19 @@ impl Exporter<'_> { s.set_stroke(None); } + /// A notes or handout page's header, date, footer and page number from its master; just the + /// page number when the deck has no such master. + fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> { + let Some(m) = master else { return self.footer(s, pw, ph, n) }; + // Laid out by the text engine in the placeholders, scaled from the notes size to the page: + // wrapping, alignment and mixed formatting as the master has them. + let pres = self.pres; + let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0)); + for pt in deckcraft_render::place_master_texts(pres, m, n) { + self.text_layer(s, &pt, view, true)?; + } + Ok(()) + } fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> { let (font, _) = self.ui_font()?; s.set_fill(Some(grey(0x60, 1.0))); @@ -446,6 +620,20 @@ impl Exporter<'_> { } } +/// Byte range in `text` (the run's characters `chars`) of each of `n` glyphs, for the ToUnicode map. +/// `cells` holds each glyph's shaping-cluster head (paragraph character index); a cluster runs to the +/// next head in the run, so a ligature maps to all its characters and every glyph of a cluster shares +/// its range (krilla then writes the text once). +fn glyph_text_ranges(text: &str, chars: (usize, usize), cells: &[(usize, char)], n: usize) -> Vec> { + let starts: Vec = text.char_indices().map(|(b, _)| b).collect(); + let byte = |ci: usize| ci.checked_sub(chars.0).and_then(|i| starts.get(i).copied()).unwrap_or(text.len()); + let mut heads: Vec = cells.iter().map(|c| c.0).collect(); + heads.sort_unstable(); + heads.dedup(); + let end = |ci: usize| heads.get(heads.partition_point(|&h| h <= ci)).copied().unwrap_or(chars.1); + (0..n).map(|gi| cells.get(gi).map_or(text.len()..text.len(), |&(ci, _)| byte(ci)..byte(end(ci)))).collect() +} + fn to_path(bp: &kurbo::BezPath) -> Option { use kurbo::PathEl; let mut pb = krilla::geom::PathBuilder::new(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index a59b6d29..fd858884 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -7,7 +7,7 @@ fn deck(n: usize) -> Presentation { let mut p = deckcraft_model::defaults::new_presentation(None); let layout = p.slides[0].layout; while p.slides.len() < n { - let s = deckcraft_model::defaults::new_slide(&mut p, layout); + let s = deckcraft_model::defaults::new_slide(&mut p, layout).expect("slide"); p.slides.push(Arc::new(s)); } for (i, s) in p.slides.iter_mut().enumerate() { @@ -112,7 +112,7 @@ fn text_layer_links_and_bookmarks_survive_serialization() { let last = p.slides[1].id; let slide = Arc::make_mut(&mut p.slides[0]); for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) { - shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false }); + shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }); } for text_layer in [true, false] { let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap(); @@ -176,6 +176,22 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() { let error = export(&p, &opts(PageLayout::Slides)).unwrap_err(); assert!(matches!(error, PdfError::Write(_))); assert!(error.to_string().contains("font could not be read for embedding")); + // Master text remains visible even when the optional slide text layer is off. + // It must propagate the same embedding error through notes and handout pages. + let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, ""); + header.text = p.slides[0].shapes[0].text.clone(); + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] { + let mut p = deck(1); + let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() })); + if layout == PageLayout::Notes { + p.notes_master = master; + } else { + p.handout_master = master; + } + let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err(); + assert!(matches!(error, PdfError::Write(_))); + assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}"); + } // The failed export leaves no poisoned writer state behind. assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok()); } @@ -261,3 +277,167 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { } assert_eq!(extracted, text); } + +/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box +/// on the page and room left for the notes. +#[test] +fn notes_and_handouts_fit_any_paper_and_orientation() { + let p = deck(3); + for (id, _, _) in PAPERS { + for landscape in [false, true] { + let paper = paper_size(id, landscape).expect("known paper"); + assert_eq!(paper.0 > paper.1, landscape, "{id}"); + let b = notes_slide_box(paper, 16.0 / 9.0); + assert!(b.x0 >= 0.0 && b.x1 <= paper.0 && b.y1 <= paper.1 * 0.5, "{id} {landscape}: {b:?}"); + assert!((b.width() / b.height() - 16.0 / 9.0).abs() < 1e-6); + for n in [1, 3, 6, 9] { + for r in handout_boxes(n, paper, 16.0 / 9.0) { + assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= paper.0 && r.y1 <= paper.1, "{id} {landscape} {n}: {r:?}"); + } + } + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 6 }] { + let bytes = export(&p, &PdfOptions { paper, ..opts(layout) }).expect("export"); + let text = String::from_utf8_lossy(&bytes); + let media = format!("/MediaBox[0 0 {} {}]", fmt(paper.0), fmt(paper.1)); + assert!(text.contains(&media), "{id} {landscape} {layout:?}: no {media}"); + } + } + } + assert_eq!(paper_size("bogus", false), None); +} + +fn fmt(v: f64) -> String { + let s = format!("{:.2}", v as f32); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn ph(kind: deckcraft_model::PhType, x: f64, y: f64, w: f64, h: f64, text: &str) -> deckcraft_model::Shape { + deckcraft_model::Shape { + xfrm: Some(deckcraft_model::Xfrm::new(x, y, w, h)), + ph: Some(deckcraft_model::Placeholder { kind, ..Default::default() }), + text: Some(TextBody::from_text(text)), + ..Default::default() + } +} + +#[test] +fn notes_pages_and_handouts_follow_their_masters() { + use deckcraft_model::PhType; + let mut p = deck(2); + let paper = (612.0, 792.0); + // No masters: the default layout (slide at the top, one-inch side margins). + let (slide, notes) = notes_frames(&p, paper); + assert_eq!((slide.x0, slide.y0), (72.0, 54.0)); + assert!(notes.y0 > slide.y1); + // A notes master moves the slide and the notes, scaled from the notes size to the paper. + p.notes_size = deckcraft_model::Size::new(612.0, 792.0); + p.notes_master = Some(Arc::new(deckcraft_model::Master { + shapes: vec![ + ph(PhType::SlideImage, 100.0, 400.0, 412.0, 300.0, ""), + ph(PhType::Body, 50.0, 50.0, 500.0, 300.0, ""), + ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Q3 review"), + ph(PhType::Date, 312.0, 0.0, 300.0, 30.0, ""), + ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "<#>"), + ], + ..Default::default() + })); + let (slide, notes) = notes_frames(&p, paper); + assert!(slide.y0 >= 400.0 && slide.y1 <= 700.0 + 1e-6 && slide.x0 >= 100.0 - 1e-6, "{slide:?}"); + assert!(((slide.width() / slide.height()) - p.slide_size.width / p.slide_size.height).abs() < 1e-6); + assert_eq!(notes, Rect::new(50.0, 50.0, 550.0, 350.0)); + let texts = master_texts(&p, p.notes_master.as_deref().unwrap(), paper, 7); + let got: Vec<(&str, bool)> = texts.iter().map(|(_, t, r, _)| (t.as_str(), *r)).collect(); + assert_eq!(got, [("Q3 review", false), ("7", true)], "empty date left out, number is the page"); + // Notes pages and handouts export with the masters. + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] { + if layout != PageLayout::Notes { + p.handout_master = Some(Arc::new(deckcraft_model::Master { + shapes: vec![ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "Confidential")], + ..Default::default() + })); + } + let bytes = export(&p, &PdfOptions { layout, ..Default::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + } +} + +#[test] +fn master_text_keeps_its_own_formatting() { + use deckcraft_model::text::RunProps; + use deckcraft_model::{ColorRef, Fill, PhType, Rgba}; + let mut p = deck(1); + p.notes_size = deckcraft_model::Size::new(612.0, 792.0); + let mut header = ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Board pack"); + if let Some(run) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()).and_then(|p| p.runs.first_mut()) { + run.props = RunProps { + size: Some(20.0), + bold: Some(true), + fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))), + font: Some("+mj-lt".into()), + ..Default::default() + }; + } + let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "plain")], ..Default::default() }; + let texts = master_texts(&p, &m, (612.0, 792.0), 1); + let st = &texts[0].3; + assert_eq!((st.size, st.bold, st.italic, st.color), (20.0, true, false, Rgba::rgb(200, 0, 0))); + assert_eq!(st.family, m.theme.font("+mj-lt"), "theme font resolved"); + // Unstyled text falls back through the master's styles; sizes scale with the paper. + let half = master_texts(&p, &m, (306.0, 396.0), 1); + assert!((half[0].3.size - 10.0).abs() < 1e-9); + assert!(texts[1].3.size > 0.0 && !texts[1].3.bold); + p.notes_master = Some(Arc::new(m)); + assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF")); +} + +#[test] +fn master_text_wraps_and_keeps_mixed_formatting() { + use deckcraft_model::text::{Run, RunProps}; + use deckcraft_model::{PhType, Rgba}; + let mut p = deck(1); + p.notes_size = deckcraft_model::Size::new(612.0, 792.0); + let mut header = ph(PhType::Header, 0.0, 0.0, 200.0, 80.0, ""); + if let Some(para) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()) { + para.runs = vec![ + Run::with("Quarterly board pack ", RunProps { size: Some(18.0), bold: Some(true), ..Default::default() }), + Run::with( + "for internal circulation only, please do not forward", + RunProps { + size: Some(9.0), + fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(200, 0, 0)))), + ..Default::default() + }, + ), + ]; + } + let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "")], ..Default::default() }; + let placed = deckcraft_render::place_master_texts(&p, &m, 4); + assert_eq!(placed.len(), 2, "header and page number"); + let h = &placed[0].layout; + assert!(h.lines.len() >= 2, "wraps inside its 200 pt placeholder: {} lines", h.lines.len()); + let sizes: Vec = h.runs.iter().filter(|r| !r.glyphs.is_empty()).map(|r| r.size).collect(); + assert!(sizes.iter().any(|s| (*s - 18.0).abs() < 0.5) && sizes.iter().any(|s| (*s - 9.0).abs() < 0.5), "{sizes:?}"); + assert!(h.runs.iter().any(|r| r.color == Rgba::rgb(200, 0, 0))); + let num: String = placed[1].body.text(); + assert_eq!(num, "4"); + // The same layout as pieces for the Notes Page view: on more than one line, scaled to paper. + let pieces = master_text_pieces(&p, &m, (306.0, 396.0), 4); + let mut ys: Vec = pieces.iter().map(|x| x.at.1.round() as i64).collect(); + ys.dedup(); + assert!(ys.len() >= 3, "two header lines and the number: {pieces:?}"); + assert!(pieces.iter().any(|x| x.bold && (x.size - 9.0).abs() < 0.5), "18 pt bold at half size"); + assert!(pieces.iter().any(|x| x.color == Rgba::rgb(200, 0, 0) && !x.bold)); + assert!(pieces.iter().any(|x| x.text == "4")); + p.notes_master = Some(Arc::new(m)); + assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF")); +} + +#[test] +fn glyph_ranges_follow_shaping_clusters() { + // "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164). + assert_eq!(glyph_text_ranges("flash", (0, 5), &[(0, 'f'), (2, 'a'), (3, 's'), (4, 'h')], 4), vec![0..2, 2..3, 3..4, 4..5]); + // Two glyphs in one cluster (base and mark) share its range. + assert_eq!(glyph_text_ranges("ab", (0, 2), &[(0, 'a'), (0, 'a'), (1, 'b')], 3), vec![0..1, 0..1, 1..2]); + // A run starting mid-paragraph, multi-byte characters in visual (right-to-left) order. + assert_eq!(glyph_text_ranges("αβγ", (3, 6), &[(5, 'γ'), (4, 'β'), (3, 'α')], 3), vec![4..6, 2..4, 0..2]); +} diff --git a/crates/pptx/src/cfb.rs b/crates/pptx/src/cfb.rs new file mode 100644 index 00000000..3155d192 --- /dev/null +++ b/crates/pptx/src/cfb.rs @@ -0,0 +1,185 @@ +//! Just enough of the Compound File Binary (CFB/OLE2) container to tell a password-protected +//! presentation apart: PowerPoint wraps an encrypted `.pptx` in a CFB holding `EncryptionInfo` +//! and `EncryptedPackage` streams (ECMA-376 / [MS-OFFCRYPTO]) instead of a zip. Every read is +//! bounds-checked and every loop bounded. Copied from GridCraft's `crates/xlsx/src/cfb.rs`. + +/// The CFB signature at the start of the file. +const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +const END_OF_CHAIN: u32 = 0xFFFF_FFFE; +/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors. +const MAX_REG_SECT: u32 = 0xFFFF_FFFA; +/// Directory sectors followed at most (4096 entries with 512-byte sectors). +const MAX_DIR_SECTORS: usize = 1024; +/// DIFAT sectors followed at most. +const MAX_DIFAT_SECTORS: usize = 1024; + +/// Stream names that mark an encrypted (password-protected) OOXML package. +const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"]; + +fn u16_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +/// Whether `bytes` is a compound file holding an encryption stream. Walks the directory through +/// the FAT; when the structure can't be followed (truncated or malformed), falls back to scanning +/// the bytes for the UTF-16LE stream names. +pub(crate) fn is_encrypted(bytes: &[u8]) -> bool { + if !bytes.starts_with(&SIGNATURE) { + return false; + } + match directory_names(bytes) { + Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))), + None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)), + } +} + +fn contains_utf16(hay: &[u8], needle: &str) -> bool { + let n: Vec = needle.encode_utf16().flat_map(u16::to_le_bytes).collect(); + !n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice()) +} + +/// Names of every directory entry, or `None` when the directory can't be read. +fn directory_names(b: &[u8]) -> Option> { + if !b.starts_with(&SIGNATURE) { + return None; + } + let shift = u16_at(b, 30)?; + if shift != 9 && shift != 12 { + return None; + } + let size = 1usize << shift; + let per = size / 4; + let sector = |id: u32| -> Option<&[u8]> { + let start = (id as usize).checked_add(1)?.checked_mul(size)?; + b.get(start..start.checked_add(size)?) + }; + // The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors. + let mut difat: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); + let mut next = u32_at(b, 68)?; + for _ in 0..MAX_DIFAT_SECTORS { + if next >= MAX_REG_SECT { + break; + } + let s = sector(next)?; + difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); + next = u32_at(s, (per - 1) * 4)?; + } + let fat_next = |id: u32| -> Option { + let idx = id as usize; + let fat_sector = *difat.get(idx / per)?; + u32_at(sector(fat_sector)?, (idx % per) * 4) + }; + let mut names = Vec::new(); + let mut id = u32_at(b, 48)?; + for _ in 0..MAX_DIR_SECTORS { + if id == END_OF_CHAIN { + return Some(names); + } + if id >= MAX_REG_SECT { + return None; + } + let s = sector(id)?; + for e in s.as_chunks::<128>().0 { + let len = usize::from(u16_at(e, 64)?); + if !(2..=64).contains(&len) { + continue; + } + let units: Vec = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); + names.push(String::from_utf16_lossy(&units)); + } + id = fat_next(id)?; + } + // A directory longer than the bound (or a cyclic chain): report what was read. + Some(names) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + /// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1) + /// whose directory holds a root entry plus the given stream names. + fn build(streams: &[&str]) -> Vec { + let mut b = vec![0u8; 512 * 3]; + b[..8].copy_from_slice(&SIGNATURE); + b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version + b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version + b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order + b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift + b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift + b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors + b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector + b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT + b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors + for i in 0..109 { + b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); + } + b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0 + // FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain. + for i in 0..128 { + b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); + } + b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes()); + b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); + // Directory (sector 1, at byte 1024). + for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() { + let e = 1024 + k * 128; + let units: Vec = name.encode_utf16().take(31).collect(); + for (i, u) in units.iter().enumerate() { + b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes()); + } + b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes()); + b[e + 66] = if k == 0 { 5 } else { 2 }; + } + b + } + + #[test] + fn reads_directory_names() { + let b = build(&["EncryptionInfo", "EncryptedPackage"]); + assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()])); + assert!(is_encrypted(&b)); + // A PowerPoint 97-2003 .ppt is a compound file too, without the encryption streams. + assert!(!is_encrypted(&build(&["PowerPoint Document", "Current User"]))); + assert!(!is_encrypted(b"PK\x03\x04")); + } + + #[test] + fn hostile_headers_never_panic() { + let mut b = build(&["PowerPoint Document"]); + for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] { + for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] { + let mut c = b.clone(); + c[at..at + 4].copy_from_slice(&v.to_le_bytes()); + let _ = is_encrypted(&c); + for cut in [0usize, 8, 100, 600, 1100] { + let _ = is_encrypted(&c[..cut.min(c.len())]); + } + } + } + // A directory chain that loops back on itself stops at the bound. + b[516..520].copy_from_slice(&1u32.to_le_bytes()); + assert!(!is_encrypted(&b)); + } + + /// #346: opening a password-protected presentation says so instead of failing as an unknown + /// file. + #[test] + fn a_password_protected_presentation_is_named_as_such() { + let locked = build(&["EncryptionInfo", "EncryptedPackage"]); + let err = deckcraft_engine::cmd::file::open_presentation("Locked.pptx", &locked).unwrap_err().to_string(); + assert!(err.contains("Locked.pptx is password-protected"), "{err}"); + let ppt = build(&["PowerPoint Document", "Current User"]); + let err = deckcraft_engine::cmd::file::open_presentation("Old.ppt", &ppt).unwrap_err().to_string(); + assert!(!err.contains("password"), "{err}"); + } +} diff --git a/crates/pptx/src/dgm.rs b/crates/pptx/src/dgm.rs new file mode 100644 index 00000000..5f85db00 --- /dev/null +++ b/crates/pptx/src/dgm.rs @@ -0,0 +1,882 @@ +//! SmartArt (DrawingML diagrams): the outline ⇄ the data model, and the layout, quick style and +//! colour definitions DeckCraft writes for its own layouts. +//! +//! A diagram's data part holds its points (`dgm:pt`: the document, one per node, and the +//! transitions PowerPoint pairs with each link) and its links (`dgm:cxn` of type `parOf`, parent → +//! child, ordered by `srcOrd`). The outline is that tree flattened depth first. + +use std::collections::HashMap; + +use deckcraft_model::{Diagram, DiagramNode}; + +use crate::xml::{A, El, W}; + +pub const NS_DGM: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram"; +pub const NS_DSP: &str = "http://schemas.microsoft.com/office/drawing/2008/diagram"; +pub const CT_DATA: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml"; +pub const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml"; +pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml"; +pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; +pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; +pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; +const QS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/"; +const CS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/colors/"; + +/// PowerPoint's quick style for a SmartArt Style. +pub fn qs_id(style: &str) -> String { + let s = match style { + "subtle" => "simple3", + "intense" => "simple5", + "outline" => "simple4", + "polished" => "3d1", + "inset" => "3d2", + "cartoon" => "3d3", + "powder" => "3d4", + "brick" => "3d5", + _ => "simple1", + }; + format!("{QS_BASE}{s}") +} + +/// The SmartArt Style closest to a quick style id. +pub fn style_for(qs: &str) -> &'static str { + match qs.rsplit('/').next().unwrap_or("") { + "simple1" | "" => "", + "simple4" => "outline", + "3d1" => "polished", + "3d2" => "inset", + "3d3" => "cartoon", + "3d4" => "powder", + "3d5" => "brick", + "simple2" | "simple3" => "subtle", + _ => "intense", + } +} + +/// PowerPoint's colour definition for Change Colors. +pub fn cs_id(colors: &str) -> String { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return format!("{CS_BASE}colorful{a}"); + } + let grad = colors.ends_with("Grad"); + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_{}", if grad { 3 } else { 2 }), + _ => format!("{CS_BASE}colorful1"), + } +} + +/// The accents a Change Colors choice fills shapes with, in turn. +fn fill_accents(colors: &str) -> Vec { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return vec![format!("accent{a}"), format!("accent{}", a + 1)]; + } + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => vec![format!("accent{n}")], + _ => (1..=6).map(|k| format!("accent{k}")).collect(), + } +} + +/// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful. +pub fn colors_for(cs: &str) -> String { + let last = cs.rsplit('/').next().unwrap_or(""); + if let Some(a @ 2..=5) = last.strip_prefix("colorful").and_then(|n| n.parse::().ok()) { + return format!("range{a}{}", a + 1); + } + let mut parts = last.strip_prefix("accent").unwrap_or("").split('_'); + match (parts.next().and_then(|n| n.parse::().ok()), parts.next()) { + (Some(n @ 1..=6), Some("3" | "4" | "5")) => format!("accent{n}Grad"), + (Some(n @ 1..=6), _) => format!("accent{n}"), + _ => String::new(), + } +} +/// Most nodes read from one diagram. +const MAX_NODES: usize = 2000; + +/// PowerPoint's built-in layout DeckCraft's layout `kind` is written as. +pub fn layout_id(kind: &str) -> &'static str { + match kind { + "blockList" => "urn:microsoft.com/office/officeart/2005/8/layout/default", + "hBulletList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList1", + "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "chevron" => "urn:microsoft.com/office/officeart/2005/8/layout/chevron1", + "verticalProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process2", + "stepUp" => "urn:microsoft.com/office/officeart/2009/3/layout/StepUpProcess", + "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "blockCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle5", + "radial" => "urn:microsoft.com/office/officeart/2005/8/layout/radial1", + "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", + "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3", + "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", + "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", + "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", + "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess10", + "pictureHList" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", + "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", + "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", + "textCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle1", + "continuousCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle3", + "horizontalHierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy2", + "gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2", + "gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1", + "funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1", + "segmentedCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle8", + "multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7", + "orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", + "halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart", + "nameTitleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/NameandTitleOrganizationalChart", + "verticalBoxList" => "urn:microsoft.com/office/officeart/2005/8/layout/list1", + "trapezoidList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList6", + "tableList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList3", + "accentProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process3", + "continuousBlockProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess9", + "bendingProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process5", + "linearVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn3", + "stackedVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn2", + "nestedTarget" => "urn:microsoft.com/office/officeart/2005/8/layout/target2", + "cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4", + "segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4", + "pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3", + _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", + } +} + +/// The DeckCraft layout closest to a PowerPoint layout (by its id and category). +pub fn kind_for(layout_id: &str, category: &str) -> &'static str { + let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // DeckCraft before Picture Accent Process got its own id wrote it as pList2 (Horizontal + // Picture List) with its own layout part in the process category; PowerPoint's pList2 is a list. + if last == "plist2" && category.eq_ignore_ascii_case("process") { + return "pictureProcess"; + } + // Exact ids first: the layouts DeckCraft writes, and their closest siblings. + for k in [ + "blockList", + "hBulletList", + "process", + "chevron", + "verticalProcess", + "stepUp", + "cycle", + "blockCycle", + "radial", + "hierarchy", + "pyramid", + "invertedPyramid", + "matrix", + "titledMatrix", + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + "cycleMatrix", + "segmentedPyramid", + "pie", + "list", + ] { + if layout_id == layout_id_of(k) { + return k; + } + } + let s = format!("{last} {}", category.to_ascii_lowercase()); + if s.contains("gear") { + "gear" + } else if s.contains("funnel") { + "funnel" + } else if s.contains("venn") { + "venn" + } else if s.contains("target") { + "target" + } else if s.contains("timeline") { + "timeline" + } else if s.contains("nameandtitle") { + "nameTitleOrgChart" + } else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") { + "orgChart" + } else if s.contains("hierarchy") { + "hierarchy" + } else if s.contains("radial") { + "radial" + } else if s.contains("cycle") { + "cycle" + } else if s.contains("pyramid") { + "pyramid" + } else if s.contains("matrix") { + "matrix" + } else if s.contains("chevron") { + "chevron" + } else if s.contains("process") || s.contains("arrow") { + "process" + } else if last == "default" { + "blockList" + } else if last.starts_with("hlist") { + "hBulletList" + } else { + "list" + } +} + +fn layout_id_of(kind: &str) -> &'static str { + layout_id(kind) +} + +fn category(kind: &str) -> &'static str { + match kind { + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "pictureProcess" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => { + "cycle" + } + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + "matrix" | "titledMatrix" | "gridMatrix" => "matrix", + _ => "list", + } +} + +/// A model id, stable for a given diagram (`seed`) and point number. +fn guid(seed: u32, n: usize) -> String { + format!("{{{seed:08X}-D1A6-4C0A-9E5B-{n:012X}}}") +} + +fn text(w: &mut W, t: &str) { + w.open0("dgm:t"); + w.empty0("a:bodyPr"); + w.empty0("a:lstStyle"); + w.open0("a:p"); + if t.is_empty() { + w.empty("a:endParaRPr", A::new().a("lang", "en-US")); + } else { + w.open0("a:r"); + w.empty("a:rPr", A::new().a("lang", "en-US")); + w.elt("a:t", t); + w.close("a:r"); + } + w.close("a:p"); + w.close("dgm:t"); +} + +/// `ppt/diagrams/dataN.xml` for the outline. `drawing_rel` is the slide's relationship id of the +/// cached drawing; `pics` the data part's relationship ids of node pictures, by node index. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, String)]) -> Vec { + let lo = if d.layout_id.is_empty() { layout_id(&d.kind).to_string() } else { d.layout_id.clone() }; + let mut w = W::new(); + w.open("dgm:dataModel", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R)); + w.open0("dgm:ptLst"); + let doc = guid(seed, 0); + w.open("dgm:pt", A::new().a("modelId", &doc).a("type", "doc")); + w.empty( + "dgm:prSet", + A::new() + .a("loTypeId", &lo) + .a("loCatId", category(&d.kind)) + .a("qsTypeId", qs_id(&d.style)) + .a("qsCatId", "simple") + .a("csTypeId", cs_id(&d.colors)) + .a("csCatId", if d.colors.starts_with("accent") { d.colors.as_str() } else { "colorful" }) + .a("phldr", "1"), + ); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + // Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k. + let node = |k: usize| guid(seed, 1 + 3 * k); + for (k, n) in d.nodes.iter().enumerate() { + let mut a = A::new().a("modelId", node(k)); + if n.assistant { + a = a.a("type", "asst"); + } + w.open("dgm:pt", a); + w.empty("dgm:prSet", A::new().a("phldrT", "[Text]")); + w.empty0("dgm:spPr"); + text(&mut w, &n.text); + w.close("dgm:pt"); + let cxn = guid(seed, 0x10_0000 + k); + for (i, ty) in [(2, "parTrans"), (3, "sibTrans")] { + w.open("dgm:pt", A::new().a("modelId", guid(seed, i + 3 * k)).a("type", ty).a("cxnId", &cxn)); + w.empty0("dgm:prSet"); + w.empty0("dgm:spPr"); + text(&mut w, ""); + w.close("dgm:pt"); + } + } + // A node's picture sits on the presentation point of its picture placeholder, as PowerPoint + // stores it (`presAssocID` names the node, the blip is a relationship of the data part). + // Name and Title: a node's title is the text of its title box's presentation point. + for (k, n) in d.nodes.iter().enumerate() { + if n.title.is_empty() { + continue; + } + w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x30_0000 + k)).a("type", "pres")); + w.empty("dgm:prSet", A::new().a("presAssocID", node(k)).a("presName", "titleText1").a("presStyleLbl", "fgAcc1").a("custT", "1")); + w.empty0("dgm:spPr"); + text(&mut w, &n.title); + w.close("dgm:pt"); + } + for (k, rid) in pics { + if *k >= d.nodes.len() { + continue; + } + w.open("dgm:pt", A::new().a("modelId", guid(seed, 0x20_0000 + k)).a("type", "pres")); + w.empty("dgm:prSet", A::new().a("presAssocID", node(*k)).a("presName", "Image").a("presStyleLbl", "fgImgPlace1")); + w.open0("dgm:spPr"); + w.open0("a:blipFill"); + w.empty("a:blip", A::new().a("r:embed", rid.as_str())); + w.open0("a:stretch"); + w.empty0("a:fillRect"); + w.close("a:stretch"); + w.close("a:blipFill"); + w.close("dgm:spPr"); + w.close("dgm:pt"); + } + w.close("dgm:ptLst"); + w.open0("dgm:cxnLst"); + let mut stack: Vec<(u8, String)> = vec![]; + let mut ords: HashMap = HashMap::new(); + for (k, n) in d.nodes.iter().enumerate() { + while stack.last().is_some_and(|(l, _)| *l >= n.level) { + stack.pop(); + } + let parent = stack.last().map(|(_, id)| id.clone()).unwrap_or_else(|| doc.clone()); + let ord = ords.entry(parent.clone()).or_insert(0); + w.empty( + "dgm:cxn", + A::new() + .a("modelId", guid(seed, 0x10_0000 + k)) + .a("srcId", &parent) + .a("destId", node(k)) + .a("srcOrd", *ord) + .a("destOrd", 0) + .a("parTransId", guid(seed, 2 + 3 * k)) + .a("sibTransId", guid(seed, 3 + 3 * k)), + ); + *ord += 1; + stack.push((n.level, node(k))); + } + w.close("dgm:cxnLst"); + w.empty0("dgm:bg"); + w.empty0("dgm:whole"); + w.open0("dgm:extLst"); + w.open("a:ext", A::new().a("uri", "http://schemas.microsoft.com/office/drawing/2008/diagram")); + w.empty( + "dsp:dataModelExt", + A::new().a("xmlns:dsp", NS_DSP).a("relId", drawing_rel).a("minVer", "http://schemas.openxmlformats.org/drawingml/2006/diagram"), + ); + w.close("a:ext"); + w.close("dgm:extLst"); + w.close("dgm:dataModel"); + w.finish() +} + +/// The outline of a data part: the `parOf` tree under the document point, depth first. +pub fn outline(data: &El) -> Vec { + outline_ids(data).into_iter().map(|(_, n)| n).collect() +} + +/// The relationship ids (of the data part) of node pictures, by node index in [`outline`]: the +/// blip fill of a presentation point associated with the node. +pub fn node_pictures(data: &El) -> Vec<(usize, String)> { + let ids: HashMap = outline_ids(data).into_iter().enumerate().map(|(k, (id, _))| (id, k)).collect(); + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut out: Vec<(usize, String)> = vec![]; + for pt in pts.children_named("pt").take(MAX_NODES * 8) { + if pt.attr("type") != Some("pres") { + continue; + } + let Some(k) = pt.child("prSet").and_then(|p| p.attr("presAssocID")).and_then(|a| ids.get(a)) else { continue }; + let Some(rid) = pt.child("spPr").and_then(|s| s.child("blipFill")).and_then(|b| b.child("blip")).and_then(|b| b.attr("r:embed")) else { + continue; + }; + if !out.iter().any(|(j, _)| j == k) { + out.push((*k, rid.to_string())); + } + } + out +} + +fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { + let Some(pts) = data.child("ptLst") else { return vec![] }; + let mut texts: HashMap<&str, String> = HashMap::new(); + let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut titles: HashMap<&str, String> = HashMap::new(); + let mut doc: Option<&str> = None; + for pt in pts.children_named("pt").take(MAX_NODES * 4) { + let Some(id) = pt.attr("modelId") else { continue }; + match pt.attr("type").unwrap_or("node") { + "doc" => doc = doc.or(Some(id)), + ty @ ("node" | "asst") => { + let t = pt.child("t").map(paragraphs).unwrap_or_default(); + texts.insert(id, t); + if ty == "asst" { + assistants.insert(id); + } + } + "pres" => { + // Name and Title: the title box's own text, on its presentation point. + let pr = pt.child("prSet"); + if pr.and_then(|p| p.attr("presName")).is_some_and(|n| n.starts_with("titleText")) + && let Some(node) = pr.and_then(|p| p.attr("presAssocID")) + && let Some(t) = pt.child("t").map(paragraphs).filter(|t| !t.is_empty()) + { + titles.insert(node, t); + } + } + _ => {} + } + } + let Some(doc) = doc else { return vec![] }; + let mut kids: HashMap<&str, Vec<(i64, &str)>> = HashMap::new(); + if let Some(cx) = data.child("cxnLst") { + for c in cx.children_named("cxn").take(MAX_NODES * 4) { + if c.attr("type").unwrap_or("parOf") != "parOf" { + continue; + } + let (Some(src), Some(dst)) = (c.attr("srcId"), c.attr("destId")) else { continue }; + if texts.contains_key(dst) { + kids.entry(src).or_default().push((c.i64("srcOrd").unwrap_or(0), dst)); + } + } + } + for v in kids.values_mut() { + v.sort_by_key(|(o, _)| *o); + } + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + // Depth first, iteratively; a node reached twice (a cycle in a hostile file) is skipped. + let mut stack: Vec<(&str, u8)> = kids.get(doc).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default(); + while let Some((id, level)) = stack.pop() { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(( + id.to_string(), + DiagramNode { + text: texts.get(id).cloned().unwrap_or_default(), + level: level.min(8), + picture: None, + assistant: assistants.contains(id), + title: titles.get(id).cloned().unwrap_or_default(), + }, + )); + if let Some(v) = kids.get(id) { + stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); + } + } + out +} + +/// A text body's paragraphs joined by spaces. +fn paragraphs(t: &El) -> String { + t.children_named("p") + .map(|p| { + let mut v = vec![]; + p.find_all("t", &mut v); + v.iter().map(|e| e.text()).collect::() + }) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" ") +} + +/// The layout algorithm and node shape of a DeckCraft layout. +fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { + match kind { + "process" | "stepUp" | "pictureProcess" | "pictureCaption" | "pictureHList" | "timeline" => { + ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect") + } + "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), + "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), + "cycle" + | "radial" + | "venn" + | "target" + | "textCycle" + | "continuousCycle" + | "gear" + | "segmentedCycle" + | "multidirectionalCycle" + | "cycleMatrix" + | "pie" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), + "pyramid" | "invertedPyramid" | "segmentedPyramid" => { + ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") + } + "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( + "snake", + vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], + if kind == "blockList" { "rect" } else { "roundRect" }, + ), + "hBulletList" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "t")], "roundRect"), + _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), + } +} + +fn node_layout(w: &mut W, shape: &str, bullets: bool) { + w.open("dgm:layoutNode", A::new().a("name", "node")); + w.open0("dgm:varLst"); + w.empty("dgm:bulletEnabled", A::new().a("val", if bullets { "1" } else { "0" })); + w.close("dgm:varLst"); + w.empty("dgm:alg", A::new().a("type", "tx")); + w.open("dgm:shape", A::new().a("type", shape).a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty("dgm:presOf", A::new().a("axis", if bullets { "desOrSelf" } else { "self" }).a("ptType", "node")); + w.open0("dgm:constrLst"); + for side in ["tMarg", "bMarg", "lMarg", "rMarg"] { + w.empty("dgm:constr", A::new().a("type", side).a("refType", "primFontSz").a("fact", "0.3")); + } + w.close("dgm:constrLst"); + w.open0("dgm:ruleLst"); + w.empty("dgm:rule", A::new().a("type", "primFontSz").a("val", "5").a("fact", "NaN").a("max", "NaN")); + w.close("dgm:ruleLst"); + w.close("dgm:layoutNode"); +} + +fn space(w: &mut W) { + w.open("dgm:forEach", A::new().a("name", "sibling").a("axis", "followSib").a("ptType", "sibTrans").a("cnt", "1")); + w.open("dgm:layoutNode", A::new().a("name", "space")); + w.empty("dgm:alg", A::new().a("type", "sp")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); +} + +/// `ppt/diagrams/layoutN.xml`: a layout definition for DeckCraft's layout `kind`, under the id of +/// the closest built-in PowerPoint layout. +pub fn layout_xml(kind: &str) -> Vec { + let mut w = W::new(); + w.open( + "dgm:layoutDef", + A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R).a("uniqueId", layout_id(kind)), + ); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", category(kind)).a("pri", "1000")); + w.close("dgm:catLst"); + w.open("dgm:layoutNode", A::new().a("name", "diagram")); + w.open0("dgm:varLst"); + w.empty0("dgm:dir"); + w.empty("dgm:resizeHandles", A::new().a("val", "exact")); + w.close("dgm:varLst"); + if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart") { + // Each node over the row of its children, recursively. + w.empty("dgm:alg", A::new().a("type", "hierChild")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "level").a("axis", "ch").a("ptType", "node")); + w.open("dgm:layoutNode", A::new().a("name", "hierRoot")); + w.empty("dgm:alg", A::new().a("type", "hierRoot")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + node_layout(&mut w, "roundRect", false); + w.open("dgm:layoutNode", A::new().a("name", "hierChild")); + w.empty("dgm:alg", A::new().a("type", "hierChild")); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.empty0("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.empty("dgm:forEach", A::new().a("name", "repeat").a("ref", "level")); + w.close("dgm:layoutNode"); + w.close("dgm:layoutNode"); + w.close("dgm:forEach"); + } else { + let (ty, params, shape) = alg(kind); + w.open("dgm:alg", A::new().a("type", ty)); + for (k, v) in params { + w.empty("dgm:param", A::new().a("type", k).a("val", v)); + } + w.close("dgm:alg"); + w.open("dgm:shape", A::new().a("r:blip", "")); + w.empty0("dgm:adjLst"); + w.close("dgm:shape"); + w.empty0("dgm:presOf"); + w.open0("dgm:constrLst"); + w.empty("dgm:constr", A::new().a("type", "w").a("for", "ch").a("forName", "node").a("refType", "w")); + w.empty( + "dgm:constr", + A::new().a("type", "h").a("for", "ch").a("forName", "node").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.6"), + ); + w.empty( + "dgm:constr", + A::new().a("type", "w").a("for", "ch").a("forName", "space").a("refType", "w").a("refFor", "ch").a("refForName", "node").a("fact", "0.1"), + ); + w.empty("dgm:constr", A::new().a("type", "primFontSz").a("for", "ch").a("forName", "node").a("op", "equ").a("val", "65")); + w.close("dgm:constrLst"); + w.empty0("dgm:ruleLst"); + w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node")); + node_layout(&mut w, shape, true); + if !matches!(kind, "cycle" | "blockCycle" | "radial") { + space(&mut w); + } + w.close("dgm:forEach"); + } + w.close("dgm:layoutNode"); + w.close("dgm:layoutDef"); + w.finish() +} + +/// The top bevel (preset, width in EMU) of a 3-D SmartArt Style. +fn bevel(style: &str) -> Option<(&'static str, i64)> { + Some(match style { + "polished" => ("circle", 76200), + "inset" => ("relaxedInset", 63500), + "cartoon" => ("softRound", 50800), + "powder" => ("convex", 101600), + "brick" => ("slope", 50800), + _ => return None, + }) +} + +/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. +pub fn style_xml(style: &str) -> Vec { + // Brick Scene looks at the diagram from above and to the side. + let camera = if style == "brick" { "isometricOffAxis2Top" } else { "orthographicFront" }; + let mut w = W::new(); + w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", qs_id(style))); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100")); + w.close("dgm:catLst"); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", camera)); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + w.open0("dgm:scene3d"); + w.empty("a:camera", A::new().a("prst", camera)); + w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t")); + w.close("dgm:scene3d"); + match bevel(style) { + Some((prst, w_emu)) => { + w.open0("dgm:sp3d"); + w.empty("a:bevelT", A::new().a("w", w_emu.to_string()).a("h", (w_emu * 3 / 5).to_string()).a("prst", prst)); + w.close("dgm:sp3d"); + } + None => w.empty0("dgm:sp3d"), + } + w.empty("dgm:txPr", A::new()); + w.open0("dgm:style"); + for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { + w.open(tag, A::new().a("idx", idx)); + w.empty("a:scrgbClr", A::new().a("r", "0").a("g", "0").a("b", "0")); + w.close(tag); + } + w.open("a:fontRef", A::new().a("idx", "minor")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("a:fontRef"); + w.close("dgm:style"); + w.close("dgm:styleLbl"); + w.close("dgm:styleDef"); + w.finish() +} + +/// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. +pub fn colors_xml(colors: &str) -> Vec { + let mut w = W::new(); + w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", cs_id(colors))); + w.empty("dgm:title", A::new().a("val", "")); + w.empty("dgm:desc", A::new().a("val", "")); + let accents = fill_accents(colors); + let single = accents.len() == 1; + let cat = if single { accents.first().map(String::as_str).unwrap_or("accent1") } else { "colorful" }; + w.open0("dgm:catLst"); + w.empty("dgm:cat", A::new().a("type", cat).a("pri", "11200")); + w.close("dgm:catLst"); + w.open("dgm:styleLbl", A::new().a("name", "node1")); + if colors.ends_with("Grad") { + // Gradient Range: the accent shaded darker across the shapes. + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + for k in 0..4 { + w.open("a:schemeClr", A::new().a("val", cat)); + w.empty("a:lumMod", A::new().a("val", (100_000 - k * 15_000).to_string())); + w.close("a:schemeClr"); + } + w.close("dgm:fillClrLst"); + } else { + w.open("dgm:fillClrLst", A::new().a("meth", "repeat")); + for a in &accents { + w.empty("a:schemeClr", A::new().a("val", a.as_str())); + } + w.close("dgm:fillClrLst"); + } + w.open("dgm:linClrLst", A::new().a("meth", "repeat")); + w.empty("a:schemeClr", A::new().a("val", "lt1")); + w.close("dgm:linClrLst"); + w.empty0("dgm:effectClrLst"); + w.empty0("dgm:txLinClrLst"); + w.empty0("dgm:txFillClrLst"); + w.empty0("dgm:txEffectClrLst"); + w.close("dgm:styleLbl"); + w.close("dgm:colorsDef"); + w.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nodes(v: &[(&str, u8)]) -> Vec { + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false, title: String::new() }).collect() + } + + #[test] + fn outline_survives_the_data_model() { + let d = + Diagram { kind: "hierarchy".into(), nodes: nodes(&[("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1), ("Board", 0)]), ..Default::default() }; + let xml = data_xml(&d, 7, "rId9", &[]); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + let s = String::from_utf8(xml).unwrap(); + assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); + for x in [ + layout_xml("hierarchy"), + layout_xml("cycle"), + style_xml("subtle"), + colors_xml(""), + colors_xml("accent3"), + style_xml("polished"), + colors_xml("range23"), + colors_xml("accent6Grad"), + layout_xml("pictureList"), + ] { + assert!(crate::xml::parse(&x).is_ok()); + } + } + + #[test] + fn assistants_survive_the_data_model() { + let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]); + v[1].assistant = true; + v[0].title = "Chief Executive".into(); + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let xml = data_xml(&d, 5, "rId1", &[]); + assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\"")); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(outline(&doc.root), d.nodes); + } + + #[test] + fn node_pictures_come_from_presentation_points() { + let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; + let xml = data_xml(&d, 3, "rId1", &[(2, "rId7".into()), (9, "rId8".into())]); + let doc = crate::xml::parse(&xml).unwrap(); + assert_eq!(node_pictures(&doc.root), vec![(2, "rId7".to_string())]); + } + + #[test] + fn style_and_colour_ids_round_trip() { + for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { + assert_eq!(style_for(&qs_id(st)), st); + } + for c in ["", "range23", "range56", "accent1", "accent4Grad"] { + assert_eq!(colors_for(&cs_id(c)), c); + } + assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); + assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); + assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); + // Picture Accent Process as an earlier DeckCraft wrote it (pList2, process category). + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "process"), "pictureProcess"); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "list"), "pictureHList"); + for k in [ + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + ] { + assert_eq!(kind_for(layout_id(k), ""), k); + } + } + + #[test] + fn hostile_links_are_bounded() { + let xml = br#"A"#; + let doc = crate::xml::parse(xml).unwrap(); + assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)])); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart"); + assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "nameTitleOrgChart"); + assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); + assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); + assert_eq!(colors_for(&cs_id("accent4")), "accent4"); + assert_eq!(colors_for(&cs_id("")), ""); + assert_eq!(style_for(&qs_id("outline")), "outline"); + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 92274b62..ec37b764 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -7,7 +7,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod cfb; mod custgeom; +mod dgm; mod maps; mod opc; mod png; @@ -16,11 +18,18 @@ mod tables; mod write; mod xml; +/// The bounded XML reader the importer uses, for callers that read kept fragments (a shape's +/// 3-D settings) instead of scanning strings. +pub use xml::{Doc as XmlDoc, El as XmlEl, parse as parse_xml}; + use deckcraft_model::Presentation; /// Extension URI of the PowerPoint 2010 section list in `presentation.xml`. pub(crate) const SECTION_EXT_URI: &str = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"; +/// Extension URI of the modern-comments anchor in a slide (names the threaded comment part). +pub(crate) const COMMENTS_EXT_URI: &str = "{6950BFC3-D8DA-4A85-94F7-54DA5524770B}"; + #[derive(Debug, thiserror::Error)] pub enum PptxError { #[error("not a PPTX file: {0}")] @@ -44,14 +53,35 @@ pub fn sniff(bytes: &[u8]) -> bool { zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false) } +/// Is this a password-protected presentation (an encrypted package inside a compound file)? +pub fn is_encrypted(bytes: &[u8]) -> bool { + cfb::is_encrypted(bytes) +} + /// Read a `.pptx` / `.potx` / `.ppsx` package. pub fn import(bytes: &[u8]) -> Result { read::import(bytes) } +/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media. +pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec), PptxError> { + read::import_theme(bytes) +} + /// Write a `.pptx` package. pub fn export(p: &Presentation) -> Result, PptxError> { - write::export(p) + write::export(p, write::CT_PRES) +} + +/// Write a package for `ext`: `potx` (template) and `ppsx` (slide show) differ from `pptx` only in +/// the main part's content type. +pub fn export_as(p: &Presentation, ext: &str) -> Result, PptxError> { + let ct = match ext { + "potx" => write::CT_TEMPLATE, + "ppsx" => write::CT_SHOW, + _ => write::CT_PRES, + }; + write::export(p, ct) } /// Content type for a file name's extension. @@ -78,8 +108,63 @@ pub(crate) fn mime_for(name: &str) -> &'static str { "wma" => "audio/x-ms-wma", "ogg" => "audio/ogg", "flac" => "audio/flac", + "glb" => "model/gltf-binary", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "fntdata" => "application/x-fontdata", _ => "application/octet-stream", } } + +#[cfg(test)] +mod import_theme_tests { + use std::io::{Read, Write}; + + use deckcraft_model::Presentation; + + fn custom() -> Presentation { + let mut p = Presentation::default(); + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + m.theme.name = "Harbour".into(); + m.theme.fonts.major.latin = "Lato".into(); + m.theme.fonts.minor.latin = "Source Sans 3".into(); + m.theme.colors.set(deckcraft_color::SchemeSlot::Accent1, deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56)); + p + } + + /// A `.thmx`: the deck's theme part as the package's main document. + fn thmx(pptx: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut theme = String::new(); + z.by_name("ppt/theme/theme1.xml").unwrap().read_to_string(&mut theme).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + out.start_file("[Content_Types].xml", o).unwrap(); + out.write_all(br#""#).unwrap(); + out.start_file("_rels/.rels", o).unwrap(); + out.write_all(br#""#).unwrap(); + out.start_file("theme/theme/theme1.xml", o).unwrap(); + out.write_all(theme.as_bytes()).unwrap(); + out.finish().unwrap().into_inner() + } + + #[test] + fn reads_the_theme_of_a_pptx_and_of_a_thmx() { + let pptx = crate::export(&custom()).unwrap(); + for bytes in [pptx.clone(), thmx(&pptx)] { + let (t, _) = crate::import_theme(&bytes).unwrap(); + assert_eq!(t.name, "Harbour"); + 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), deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56)); + } + } + + #[test] + fn non_theme_packages_are_errors() { + assert!(crate::import_theme(b"not a zip").is_err()); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + out.start_file("[Content_Types].xml", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(b"").unwrap(); + assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err()); + } +} diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs index dba4b5c2..0dfc8aec 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -304,6 +304,12 @@ const SUBTYPES: &[(&str, &[(&str, u32)])] = &[ ("blinds", HV), ("swivel", HV), ("swivelOut", HV), + ("blindsOut", HV), + ("boxOut", INOUT), + ("checkerboardOut", &[("across", 10), ("down", 5)]), + ("diamondOut", INOUT), + ("peekOut", DIR4), + ("stripsOut", &[("lu", 9), ("ru", 3), ("ld", 12), ("rd", 6)]), ("checkerboard", &[("across", 10), ("down", 5)]), ("wheel", WHEEL), ("wheelOut", WHEEL), @@ -352,6 +358,10 @@ pub fn fallback_effect(class: AnimClass) -> &'static str { /// The preset id to write for an animation. pub fn preset_for(a: &deckcraft_model::Animation) -> u32 { + // Float Down is PowerPoint's preset 47 (Float Up is 42). + if matches!(a.effect.as_str(), "float" | "floatOut") && a.option == "d" { + return 47; + } if let Some(p) = a.preset_id { let consistent = match effect_for_preset(a.class, p) { Some(e) => e == a.effect, @@ -377,6 +387,25 @@ mod tests { use super::*; use crate::xml::parse; + /// #147: these exits, with their directions, fell back to a plain fade (PowerPoint's ids). + #[test] + fn exit_presets_import_as_their_own_effects() { + for (preset, sub, effect, option) in [ + (3, 10, "blindsOut", "horz"), + (4, 32, "boxOut", "out"), + (5, 5, "checkerboardOut", "down"), + (8, 16, "diamondOut", "in"), + (12, 4, "peekOut", "b"), + (18, 6, "stripsOut", "rd"), + (37, 0, "sinkDown", ""), + (49, 0, "spinnerOut", ""), + (55, 0, "contract", ""), + ] { + let e = effect_for_preset(AnimClass::Exit, preset).expect("known exit"); + assert_eq!((e, option_from_subtype(e, sub).as_str()), (effect, option), "preset {preset}"); + } + } + #[test] fn every_transition_kind_round_trips() { for (kind, _, _, _, opts) in deckcraft_model::anim::TRANSITIONS { diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs index 4ef21c3d..0e7f86fe 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -12,6 +12,9 @@ pub const MAX_PART: u64 = 512 * 1024 * 1024; pub const MAX_XML_PART: usize = 96 * 1024 * 1024; /// Total decompressed bytes accepted for one package. pub const MAX_TOTAL: u64 = 2 * 1024 * 1024 * 1024; +/// Total bytes we are willing to decompress for one package, counting parts we then discard +/// (too large, or failing part-way): bounds inflate work, not just what is kept. +pub const MAX_INFLATE_WORK: u64 = 2 * MAX_TOTAL; /// Maximum number of zip entries. pub const MAX_ENTRIES: usize = 50_000; @@ -24,9 +27,13 @@ pub const NS_P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/ma pub const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main"; pub const NS_P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"; pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main"; +/// Modern (threaded) comments, Office 2018/8 schema. +pub const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/main"; pub const RT_BASE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; pub const RT_MEDIA: &str = "http://schemas.microsoft.com/office/2007/relationships/media"; +/// A 3-D model part (`.glb`). Office relationship, not an ECMA-376 base type. +pub const RT_MODEL3D: &str = "http://schemas.microsoft.com/office/2017/06/relationships/model3d"; pub const RT_CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"; /// The files of a package, keyed by normalised part name (no leading slash, lowercase). @@ -36,13 +43,22 @@ pub struct Package { impl Package { pub fn open(bytes: &[u8]) -> Result { + Self::open_within(bytes, MAX_PART, MAX_TOTAL, MAX_INFLATE_WORK) + } + /// [`Package::open`] with explicit limits: per part, kept in total, and inflated in total. + fn open_within(bytes: &[u8], max_part: u64, max_total: u64, max_work: u64) -> Result { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| PptxError::NotPptx(format!("{e}")))?; if zip.len() > MAX_ENTRIES { return Err(PptxError::NotPptx(format!("too many entries ({})", zip.len()))); } let mut parts = HashMap::new(); let mut total: u64 = 0; + let mut work: u64 = 0; for i in 0..zip.len() { + if work >= max_work { + log::warn!("pptx: decompression budget spent; skipping the remaining {} entries", zip.len() - i); + break; + } let mut f = match zip.by_index(i) { Ok(f) => f, Err(e) => { @@ -55,13 +71,16 @@ impl Package { } let name = f.name().trim_start_matches('/').to_string(); let declared = f.size(); - if declared > MAX_PART { + if declared > max_part { log::warn!("pptx: skipping oversized part {name} ({declared} bytes)"); continue; } let mut data = Vec::with_capacity(declared.min(16 * 1024 * 1024) as usize); - let cap = MAX_PART.min(MAX_TOTAL.saturating_sub(total)); - match (&mut f).take(cap.saturating_add(1)).read_to_end(&mut data) { + let cap = max_part.min(max_total.saturating_sub(total)).min(max_work.saturating_sub(work)); + let read = (&mut f).take(cap.saturating_add(1)).read_to_end(&mut data); + // Charge what was inflated whether or not the part is kept. + work = work.saturating_add(data.len() as u64); + match read { Ok(_) if data.len() as u64 > cap => { log::warn!("pptx: part {name} exceeds size limits; skipped"); continue; @@ -80,6 +99,15 @@ impl Package { pub fn get(&self, name: &str) -> Option<&[u8]> { self.parts.get(&norm(name)).map(|(_, d)| d.as_slice()) } + /// An XML part's bytes, when it is within the XML size limit. + pub fn xml_bytes(&self, name: &str) -> Option<&[u8]> { + let b = self.get(name)?; + if b.len() > MAX_XML_PART { + log::warn!("pptx: XML part {name} too large"); + return None; + } + Some(b) + } /// Parse an XML part. pub fn xml(&self, name: &str) -> Option { let b = self.get(name)?; @@ -99,15 +127,7 @@ impl Package { pub fn rels(&self, part: &str) -> Rels { let rp = rels_path(part); let Some(doc) = self.xml(&rp) else { return Rels::default() }; - let base = dir_of(part); - let mut out = Rels::default(); - for r in doc.root.children_named("Relationship") { - let (Some(id), Some(ty), Some(target)) = (r.attr("Id"), r.attr("Type"), r.attr("Target")) else { continue }; - let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")); - let target = if external { target.to_string() } else { resolve(&base, target) }; - out.items.push(Rel { id: id.to_string(), kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), target, external }); - } - out + rels_of(&doc, part) } /// Content types: (extension defaults, part overrides). pub fn content_type(&self, part: &str) -> Option { @@ -129,11 +149,47 @@ impl Package { } } +/// The relationships in a parsed rels part, with internal targets resolved against `part`'s folder. +fn rels_of(doc: &xml::Doc, part: &str) -> Rels { + let base = dir_of(part); + let mut out = Rels::default(); + for r in doc.root.children_named("Relationship") { + let (Some(id), Some(ty), Some(target)) = (r.attr("Id"), r.attr("Type"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")); + let target_raw = target.to_string(); + let target = if external { target_raw.clone() } else { resolve(&base, target) }; + out.items.push(Rel { + id: id.to_string(), + kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), + ty: ty.to_string(), + target_raw, + target, + external, + }); + } + out +} + +/// The relationships of `part` from the bytes of its rels part (kept parts carried by a chart). +pub fn rels_from_bytes(bytes: &[u8], part: &str) -> Rels { + match xml::parse(bytes) { + Ok(doc) => rels_of(&doc, part), + Err(e) => { + log::warn!("pptx: relationships of {part}: {e}"); + Rels::default() + } + } +} + #[derive(Clone, Debug, Default)] pub struct Rel { pub id: String, /// Last segment of the relationship type (`slide`, `image`, `media`…). pub kind: String, + /// Full relationship type URI. + pub ty: String, + /// `Target` as written in the rels part (relative path or URL). + pub target_raw: String, /// Absolute part name for internal targets; the URL for external ones. pub target: String, pub external: bool, @@ -163,6 +219,28 @@ pub fn norm(name: &str) -> String { name.trim_start_matches('/').replace('\\', "/").to_ascii_lowercase() } +/// A part a chart may carry along beside its own `chartN.xml` (workbook, style, drawing, picture). +/// Chart documents themselves are rewritten, and nothing outside these folders is copied: a relationship +/// must not be able to replace `ppt/slides/slide1.xml` on save. +pub fn sidecar_part(name: &str) -> bool { + let n = norm(name); + let segs: Vec<&str> = n.split('/').filter(|s| !s.is_empty()).collect(); + if segs.iter().any(|s| *s == "." || *s == "..") { + return false; + } + let (Some(root), Some(area)) = (segs.first().copied(), segs.get(1).copied()) else { return false }; + if root != "ppt" || !matches!(area, "embeddings" | "charts" | "drawings" | "media") { + return false; + } + let file = segs.last().copied().unwrap_or(""); + let stem = file.strip_prefix("chart").unwrap_or(""); + let num = stem.strip_suffix(".xml.rels").or_else(|| stem.strip_suffix(".xml")).unwrap_or(""); + if stem != file && !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) { + return false; + } + true +} + pub fn dir_of(part: &str) -> String { let p = part.trim_start_matches('/'); match p.rfind('/') { @@ -311,6 +389,10 @@ impl PackageOut { } self.files.push((rels_path(part), rels.to_xml(), true)); } + /// Add a part with no content type of its own (a relationships part). + pub fn raw(&mut self, name: &str, data: Vec) { + self.files.push((name.to_string(), data, true)); + } /// Add a binary part; its extension gets a Default content type. pub fn binary(&mut self, name: &str, content_type: &str, data: Vec) { let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase(); @@ -337,7 +419,14 @@ impl PackageOut { let err = |e: &dyn std::fmt::Display| PptxError::Write(e.to_string()); zw.start_file("[Content_Types].xml", deflate).map_err(|e| err(&e))?; zw.write_all(&ct.finish()).map_err(|e| err(&e))?; + let mut written = std::collections::HashSet::new(); for (name, data, compress) in &self.files { + // Zip entry names must be unique: a second part under a name already written is + // dropped rather than producing a package readers reject or resolve differently. + if !written.insert(norm(name)) { + log::warn!("pptx: part {name} written twice; keeping the first"); + continue; + } let large = data.len() as u64 >= u32::MAX as u64 / 2; let opts = if *compress { deflate } else { stored }.large_file(large); zw.start_file(name.as_str(), opts).map_err(|e| err(&e))?; @@ -352,6 +441,39 @@ impl PackageOut { mod tests { use super::*; + fn zip_of(entries: &[(&str, usize)]) -> Vec { + let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, len) in entries { + zw.start_file(*name, o).unwrap(); + zw.write_all(&vec![b'x'; *len]).unwrap(); + } + zw.finish().unwrap().into_inner() + } + + #[test] + fn discarded_parts_count_against_the_inflate_budget() { + // Kept bytes cap at 100: b, c and d are inflated past what's left and discarded. That + // work is charged, so after d the budget (130) is spent and e is not read, although it + // would have fit. + let z = zip_of(&[("a", 80), ("b", 80), ("c", 80), ("d", 80), ("e", 5)]); + let p = Package::open_within(&z, 1000, 100, 130).unwrap(); + assert!(p.get("a").is_some()); + assert!(p.get("b").is_none() && p.get("d").is_none()); + assert!(p.get("e").is_none(), "the inflate budget stops reading"); + // With room to spare the same small part is kept. + let p = Package::open_within(&z, 1000, 100, 10_000).unwrap(); + assert!(p.get("e").is_some()); + } + + #[test] + fn xml_bytes_applies_the_xml_limit() { + let z = zip_of(&[("small.xml", 10)]); + let p = Package::open(&z).unwrap(); + assert_eq!(p.xml_bytes("small.xml").map(<[u8]>::len), Some(10)); + assert!(p.xml_bytes("missing.xml").is_none()); + } + #[test] fn path_resolution() { assert_eq!(resolve("ppt/slides", "../media/image1.png"), "ppt/media/image1.png"); @@ -364,4 +486,17 @@ mod tests { assert_eq!(relative("ppt/presentation.xml", "ppt/slides/slide1.xml"), "slides/slide1.xml"); assert_eq!(relative("ppt/slides/slide1.xml", "ppt/slides/slide2.xml"), "slide2.xml"); } + + #[test] + fn sidecar_parts_stay_inside_chart_folders() { + assert!(sidecar_part("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx")); + assert!(sidecar_part("ppt/charts/style1.xml")); + assert!(sidecar_part("ppt/drawings/chartDrawing1.xml")); + assert!(sidecar_part("ppt/media/image1.png")); + assert!(!sidecar_part("ppt/charts/chart1.xml")); + assert!(!sidecar_part("ppt/charts/_rels/chart1.xml.rels")); + assert!(!sidecar_part("ppt/slides/slide1.xml")); + assert!(!sidecar_part("ppt/embeddings/../../ppt/slides/slide1.xml")); + assert!(!sidecar_part("/etc/passwd")); + } } diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..9055b2d9 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -1,6 +1,6 @@ //! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`. -use deckcraft_model::chart::{Chart, ChartType, Series}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::xml::El; @@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option { Some(l) } -fn rich_text(t: &El) -> Option { +pub(crate) fn rich_text(t: &El) -> Option { let tx = t.child("tx")?; if let Some(r) = tx.child("rich") { let s = r.children_named("p").map(|p| p.text()).collect::>().join("\n"); @@ -35,19 +35,34 @@ fn rich_text(t: &El) -> Option { tx.find("v").map(|v| v.text()) } -/// String values from `c:cat`/`c:tx` (str or num refs, caches or literals). -fn strings(e: &El) -> Vec { +/// Most category/value/size slots one chart may decode across all its series and caches, so +/// sparse `ptCount`/`idx` declarations can't expand far beyond the XML they came from. +pub(crate) const MAX_CHART_POINTS: usize = 1_000_000; + +/// Most slots one cache may hold. +const MAX_CACHE_POINTS: usize = 100_000; + +/// String values from `c:cat`/`c:tx` (str or num refs, caches or literals). Every slot returned +/// is taken from `budget`; once it runs out the values are cut short. +fn strings(e: &El, budget: &mut usize) -> Vec { + let cap = MAX_CACHE_POINTS.min(*budget); + let out = strings_capped(e, cap); + *budget = budget.saturating_sub(out.len()); + out +} + +fn strings_capped(e: &El, cap: usize) -> Vec { let cache = e.find("strCache").or_else(|| e.find("numCache")).or_else(|| e.find("strLit")).or_else(|| e.find("numLit")); if let Some(c) = cache { - let n = c.child("ptCount").and_then(|p| p.u32("val")).unwrap_or(0).min(100_000) as usize; + let n = (c.child("ptCount").and_then(|p| p.u32("val")).unwrap_or(0) as usize).min(cap); let mut out = vec![String::new(); n]; - for pt in c.children_named("pt").take(100_000) { + for pt in c.children_named("pt").take(MAX_CACHE_POINTS) { let i = pt.u32("idx").unwrap_or(0) as usize; + if i >= cap { + continue; + } let v = pt.child("v").map(|v| v.text()).unwrap_or_default(); if i >= out.len() { - if i >= 100_000 { - continue; - } out.resize(i + 1, String::new()); } if let Some(s) = out.get_mut(i) { @@ -59,17 +74,19 @@ fn strings(e: &El) -> Vec { if let Some(ml) = e.find("multiLvlStrCache") { // Use the innermost level. if let Some(l) = ml.child("lvl") { - return l.children_named("pt").map(|p| p.child("v").map(|v| v.text()).unwrap_or_default()).collect(); + return l.children_named("pt").take(cap).map(|p| p.child("v").map(|v| v.text()).unwrap_or_default()).collect(); } } - if let Some(v) = e.child("v") { + if cap > 0 + && let Some(v) = e.child("v") + { return vec![v.text()]; } vec![] } -fn numbers(e: &El) -> Vec> { - strings(e).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect() +fn numbers(e: &El, budget: &mut usize) -> Vec> { + strings(e, budget).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect() } /// Parse a chart part. Returns `None` for something that isn't a chart we can read. @@ -86,6 +103,11 @@ pub fn read_chart_xml(bytes: &[u8]) -> Option { } pub(crate) fn chart_from(root: &El) -> Option { + chart_within(root, MAX_CHART_POINTS) +} + +/// [`chart_from`] decoding at most `budget` data slots in all. +fn chart_within(root: &El, mut budget: usize) -> Option { let ch = root.child("chart")?; let pa = ch.child("plotArea")?; let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; @@ -166,18 +188,27 @@ pub(crate) fn chart_from(root: &El) -> Option { ChartType::Radar } } - "stockChart" => ChartType::Stock, + "stockChart" => { + c.up_down_bars = g.child("upDownBars").is_some(); + ChartType::Stock + } "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; - if let Some(v) = g.child_val("varyColors") { - c.vary_colors = v == "1" || v == "true"; - } + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; } if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; } if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { c.hole_size = (v / 100.0).clamp(0.0, 0.9); @@ -190,7 +221,7 @@ pub(crate) fn chart_from(root: &El) -> Option { let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { let mut ser = Series { - name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(), + name: s.child("tx").map(|t| strings(t, &mut budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), fill: simple_fill(s.child("spPr")), line: simple_line(s.child("spPr")), smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), @@ -202,6 +233,11 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) { ser.data_labels = Some(d); } + for t in s.children_named("trendline").take(16) { + if let Some(t) = trendline(t) { + ser.trendlines.push(t); + } + } // Data labels' number format: the series' labels', the group's, else the source data's. let label_fmt = |d: Option<&El>| { d.and_then(|d| d.child("numFmt")) @@ -218,7 +254,7 @@ pub(crate) fn chart_from(root: &El) -> Option { } } if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { - let cats = strings(cat); + let cats = strings(cat, &mut budget); if matches!(kind, ChartType::Scatter | ChartType::Bubble) { ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); } @@ -227,14 +263,33 @@ pub(crate) fn chart_from(root: &El) -> Option { } } if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { - ser.values = numbers(v); + ser.values = numbers(v, &mut budget); + } + // Error bars on the values: a scatter chart's y bars, any other chart's only ones. + if let Some(e) = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) + { + let d = ErrorBars::default(); + ser.error_bars = Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), + minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }); } if let Some(b) = s.child("bubbleSize") { - ser.sizes = numbers(b); + ser.sizes = numbers(b, &mut budget); } c.series.push(ser); } } + // `autoTitleDeleted val="0"` without `c:title`: PowerPoint and LibreOffice draw the series name as the title of a single-series chart. + if ch.child("title").is_none() && ch.child("autoTitleDeleted").and_then(|a| a.bool("val")) == Some(false) && c.series.len() == 1 { + c.title = c.series.first().map(|s| s.name.clone()).filter(|n| !n.is_empty()); + } let first = *kinds.first()?; c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first }; if c.kind != ChartType::Combo { @@ -249,7 +304,22 @@ pub(crate) fn chart_from(root: &El) -> Option { let by_id = |id: &Option| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref()); let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); - let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string); + // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no + // c:numFmt at all), in which case the data's format of the first series plotted on it. + let source_format = |ax: &El| { + let id = ax.child_val("axId"); + pa.elements() + .filter(|g| g.children_named("axId").any(|a| a.attr("val") == id)) + .flat_map(|g| g.children_named("ser")) + .find_map(|s| { + s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()) + }) + .filter(|f| !f.is_empty() && f != "General") + }; + let format_code = |a: &El| match a.child("numFmt") { + Some(n) if n.attr("sourceLinked") != Some("1") => n.attr("formatCode").map(str::to_string), + n => source_format(a).or_else(|| n.and_then(|n| n.attr("formatCode")).map(str::to_string)), + }; let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true); if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) { let primary_id = val_ax.and_then(|a| a.child_val("axId")); @@ -266,25 +336,161 @@ pub(crate) fn chart_from(root: &El) -> Option { let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a))); if let Some(v) = val_ax { c.gridlines = v.child("majorGridlines").is_some(); + c.minor_gridlines = v.child("minorGridlines").is_some(); let sc = v.child("scaling"); c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); c.y_major = num(v, "majorUnit"); + c.y_log_base = sc.and_then(|s| s.child_val("logBase")).and_then(|v| v.parse().ok()).filter(|b: &f64| b.is_finite() && *b >= 2.0); c.y_format = format_code(v); c.y_hidden = deleted(v); } if let Some(a) = cat_ax { + c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size); + // Text colour likewise: the axes' and legend's, else the whole chart's. + c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); Some(c) } +fn trend_num(e: &El, k: &str) -> Option { + e.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && v.abs() <= 1.0e12) +} + +fn trend_name(s: &str) -> Option { + let cleaned: String = + s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { None } else { Some(cleaned.to_string()) } +} + +/// One `c:trendline`. Unknown types are skipped. +fn trendline(e: &El) -> Option { + let kind = TrendlineType::from_xml(e.child_val("trendlineType")?)?; + Some(Trendline { + kind, + name: e.child("name").map(|n| n.text()).as_deref().and_then(trend_name), + order: if kind == TrendlineType::Poly { e.child("order").and_then(|o| o.u32("val")).map(|v| v.clamp(2, 6)) } else { None }, + period: if kind == TrendlineType::MovingAvg { e.child("period").and_then(|o| o.u32("val")).map(|v| v.clamp(2, 255)) } else { None }, + forward: trend_num(e, "forward"), + backward: trend_num(e, "backward"), + intercept: trend_num(e, "intercept"), + disp_eq: e.child("dispEq").and_then(|d| d.bool("val")).unwrap_or(false), + disp_r_sqr: e.child("dispRSqr").and_then(|d| d.bool("val")).unwrap_or(false), + }) +} + +/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`. +fn text_color(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is("defRPr") || c.is("rPr")) + && let Some(Fill::Solid { color }) = simple_fill(Some(c)) + { + return Some(color); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + +const MAX_KEPT_PARTS: usize = 256; +const MAX_KEPT_DEPTH: usize = 8; + +/// Relationships and parts a chart points at, so a later save can write them back. +pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckcraft_model::chart::ChartKept { + use deckcraft_model::chart::{ChartKept, ChartRel}; + + let mut kept = ChartKept::default(); + // The chart document itself is rewritten; don't store a second copy of it. + let mut seen = std::collections::HashMap::from([(crate::opc::norm(chart_part), true)]); + for r in pkg.rels(chart_part).items { + if !safe_rel(&r.id, &r.ty, &r.target_raw) { + log::warn!("pptx: skipping chart relationship {}", r.id); + continue; + } + if r.external { + kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: String::new(), external: true }); + continue; + } + if copy_part(pkg, &r.target, &mut kept.parts, &mut seen, 0) { + kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false }); + } + } + kept +} + +fn safe_rel(id: &str, ty: &str, target: &str) -> bool { + [id, ty, target].iter().all(|s| !s.is_empty() && s.len() <= 4096 && !s.chars().any(|c| c.is_control() || matches!(c, '<' | '>' | '"' | '\''))) +} + +/// `true` when `part` is stored (now or from an earlier relationship). +fn copy_part( + pkg: &crate::opc::Package, + part: &str, + out: &mut Vec, + seen: &mut std::collections::HashMap, + depth: usize, +) -> bool { + use deckcraft_model::chart::ChartPart; + + if depth > MAX_KEPT_DEPTH || out.len() >= MAX_KEPT_PARTS { + log::warn!("pptx: chart part closure stopped at {part}"); + return false; + } + let key = crate::opc::norm(part); + if let Some(ok) = seen.get(&key) { + return *ok; + } + if !crate::opc::sidecar_part(part) { + log::warn!("pptx: chart relationship target {part} is not kept"); + seen.insert(key, false); + return false; + } + let Some(bytes) = pkg.get(part) else { + log::warn!("pptx: chart relationship target {part} is missing"); + seen.insert(key, false); + return false; + }; + let name = pkg.original_name(part).unwrap_or(part).trim_start_matches('/').to_string(); + if !crate::opc::sidecar_part(&name) { + log::warn!("pptx: chart part name {name} is not kept"); + seen.insert(key, false); + return false; + } + let content_type = pkg.content_type(part).unwrap_or_default(); + out.push(ChartPart { name, content_type, bytes: bytes.to_vec() }); + seen.insert(key, true); + let rels_name = crate::opc::rels_path(part); + if crate::opc::sidecar_part(&rels_name) + && seen.get(&crate::opc::norm(&rels_name)).is_none() + && let Some(rels_bytes) = pkg.get(&rels_name) + { + seen.insert(crate::opc::norm(&rels_name), true); + let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string(); + out.push(ChartPart { name, content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), bytes: rels_bytes.to_vec() }); + let targets: Vec = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect(); + for target in targets { + copy_part(pkg, &target, out, seen, depth + 1); + } + } + true +} + /// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). fn text_size(e: Option<&El>) -> Option { fn rec(e: &El, depth: usize) -> Option { @@ -310,6 +516,56 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn sparse_caches_share_one_chart_wide_budget() { + // Every series declares 100000 points but writes one: without a chart-wide budget each + // would expand to the full count. + let ser = |n: usize| { + format!( + r#"{n}"# + ) + }; + let sers: String = (0..5).map(ser).collect(); + let xml = format!( + r#"{sers}"# + ); + let doc = crate::xml::parse(xml.as_bytes()).unwrap(); + let c = chart_within(&doc.root, 250_000).unwrap(); + let total: usize = c.series.iter().map(|s| s.values.len()).sum(); + assert!(total <= 250_000, "{total}"); + assert_eq!(c.series.len(), 5); + assert_eq!(c.series[0].values.len(), 100_000); + assert_eq!(c.series[0].values[0], Some(0.0)); + assert!(c.series[4].values.is_empty(), "the budget ran out before the last series"); + // A small chart is untouched by the default budget. + let c = chart_from(&doc.root).unwrap(); + assert!(c.series.iter().all(|s| s.values.len() == 100_000)); + } + + #[test] + fn minor_gridlines_are_read_and_written_back() { + let mut c = Chart::sample(ChartType::Line); + c.minor_gridlines = true; + let back = read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap(); + assert!(back.gridlines && back.minor_gridlines); + c.minor_gridlines = false; + assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines); + } + + #[test] + fn log_base_is_read() { + let xml = br#""#; + assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); + } + + #[test] + fn error_bars_are_read() { + let xml = br#"5"#; + let c = read_chart_xml(xml).unwrap(); + let e = c.series[0].error_bars.clone().unwrap(); + assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; @@ -363,6 +619,37 @@ mod tests { assert!(c.series.iter().all(|s| !s.secondary_axis)); } + #[test] + fn linked_axis_formats_come_from_the_data() { + // No c:numFmt on the value axis, or a source-linked one: the series' own format. + let xml = |ax_fmt: &str| { + format!( + r#"#,##01200{ax_fmt}"# + ) + }; + let fmt = |x: &str| read_chart_xml(xml(x).as_bytes()).unwrap().y_format; + assert_eq!(fmt("").as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("0.0")); + } + + #[test] + fn text_colour_is_read_from_the_axes() { + // LibreOffice's templates set light axis text for dark slides on the axes' c:txPr. + let xml = r#"1"#; + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + assert!(!c.cat_reversed); + let rev = read_chart_xml( + xml.replace("", "") + .as_bytes(), + ) + .unwrap(); + assert!(rev.cat_reversed, "maxMin reverses the category axis"); + // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. + assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); + } + #[test] fn text_sizes_are_read_from_the_file() { // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to @@ -388,4 +675,71 @@ mod tests { ); assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one"); } + + #[test] + fn trendlines_are_read() { + let xml = br#"Spend +Fit & forecast + + + +1"#; + let c = read_chart_xml(xml).unwrap(); + let t = &c.series[0].trendlines; + assert_eq!(t.len(), 3); + assert_eq!(t[0].name.as_deref(), Some("Fit & forecast")); + assert_eq!(t[0].kind, TrendlineType::Linear); + assert_eq!((t[0].forward, t[0].backward, t[0].intercept), (Some(1.0), Some(0.5), Some(0.0))); + assert!(t[0].disp_eq && t[0].disp_r_sqr); + assert_eq!((t[1].kind, t[1].order), (TrendlineType::Poly, Some(6))); + assert_eq!((t[2].kind, t[2].period), (TrendlineType::MovingAvg, Some(3))); + } + + const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + fn chart(auto_deleted: &str, title: &str, series: usize) -> Chart { + let val = r#"1"#; + let ser: String = (0..series).map(|i| format!(r#"S{i}{val}"#)).collect(); + let plot = format!(r#"{ser}"#); + let xml = format!(r#"{title}{auto_deleted}{plot}"#); + read_chart_xml(xml.as_bytes()).unwrap() + } + + #[test] + fn single_series_gets_automatic_title() { + assert_eq!(chart(r#""#, "", 1).title.as_deref(), Some("S0")); + } + + #[test] + fn no_automatic_title_when_deleted_or_multi_series() { + assert_eq!(chart(r#""#, "", 1).title, None); + assert_eq!(chart("", "", 1).title, None); + assert_eq!(chart(r#""#, "", 2).title, None); + } + + #[test] + fn explicit_title_wins() { + let t = "Mine"; + assert_eq!(chart(r#""#, t, 1).title.as_deref(), Some("Mine")); + } + + const NS_CHART: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + fn vary_xml(vary: &str) -> String { + let ser = r#""#; + let bar = format!(r#"{vary}{ser}"#); + format!(r#"{bar}"#) + } + + #[test] + fn missing_vary_colors_means_vary_by_point() { + assert!(read_chart_xml(vary_xml("").as_bytes()).is_some_and(|c| c.vary_colors)); + } + + #[test] + fn explicit_vary_colors_is_respected() { + assert!(read_chart_xml(vary_xml(r#""#).as_bytes()).is_some_and(|c| !c.vary_colors)); + assert!(read_chart_xml(vary_xml(r#""#).as_bytes()).is_some_and(|c| c.vary_colors)); + } } diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs new file mode 100644 index 00000000..d8f3e9ce --- /dev/null +++ b/crates/pptx/src/read/chartex.rs @@ -0,0 +1,143 @@ +//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap, +//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of +//! points, and each series names its layout (`layoutId`). + +use super::chart::rich_text; +use crate::xml::El; +use deckcraft_model::chart::{Chart, ChartType, Series}; +use std::collections::HashMap; + +/// Points per level and levels per dimension we keep (guards against hostile parts). +const MAX_POINTS: usize = 100_000; + +/// One `cx:data`: its categories (the innermost level) and named numeric dimensions. +#[derive(Default)] +struct Data { + categories: Vec, + /// Outer category levels, innermost first. + levels: Vec>, + numbers: HashMap>, Option)>, +} + +/// A level's points, by `idx`. +fn points(lvl: &El) -> Vec { + let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize; + let mut out = vec![String::new(); n]; + for pt in lvl.children_named("pt").take(MAX_POINTS) { + let i = pt.u32("idx").unwrap_or(0) as usize; + if i >= MAX_POINTS { + continue; + } + if i >= out.len() { + out.resize(i + 1, String::new()); + } + if let Some(s) = out.get_mut(i) { + *s = pt.text(); + } + } + out +} + +fn data(d: &El) -> Data { + let mut out = Data::default(); + for dim in d.elements() { + // The first level is the innermost: a treemap's leaves, a waterfall's categories. + let Some(lvl) = dim.child("lvl") else { continue }; + let ty = dim.attr("type").unwrap_or(""); + if dim.is("strDim") || (dim.is("numDim") && ty == "cat") { + if ty == "cat" || out.categories.is_empty() { + out.categories = points(lvl); + // Further levels are the hierarchy's parents, innermost first. + out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect(); + } + } else if dim.is("numDim") { + let vals = points(lvl).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect(); + out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string))); + } + } + out +} + +fn kind_of(layout: &str) -> Option { + Some(match layout { + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "clusteredColumn" => ChartType::Histogram, + "boxWhisker" => ChartType::BoxWhisker, + "regionMap" => ChartType::RegionMap, + _ => return None, + }) +} + +/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type for. +pub fn read_chartex_xml(bytes: &[u8]) -> Option { + let doc = crate::xml::parse(bytes).ok()?; + let mut root = doc.root; + super::resolve_mc(&mut root, 0); + let mut c = chartex_from(&root)?; + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + Some(c) +} + +pub(crate) fn chartex_from(root: &El) -> Option { + if !root.is("chartSpace") { + return None; + } + let datas: HashMap = + root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect(); + let ch = root.child("chart")?; + let region = ch.child("plotArea")?.child("plotAreaRegion")?; + let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; + c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into())); + c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string()); + let mut kind = None; + for s in region.children_named("series") { + let layout = s.attr("layoutId").unwrap_or(""); + if layout == "paretoLine" { + // The cumulative line PowerPoint adds to a Pareto histogram. + kind = Some(ChartType::Pareto); + continue; + } + let k = kind_of(layout)?; + if s.bool("hidden") == Some(true) { + continue; + } + kind = Some(match kind { + Some(ChartType::Pareto) => ChartType::Pareto, + _ => k, + }); + let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?; + let (values, format) = + ["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); + if c.categories.is_empty() { + c.categories = d.categories.clone(); + c.category_levels = d.levels.clone(); + } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) { + c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive"); + } + if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) { + c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0); + c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) { + c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect(); + } + let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default(); + c.series.push(Series { + name, + values, + data_labels: s.child("dataLabels").map(|l| l.bool("hidden") != Some(true)), + number_format: format, + ..Default::default() + }); + } + c.kind = kind?; + c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true)); + c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall); + // Waterfall, histogram and box charts have a value axis with gridlines by default. + c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker); + Some(c) +} diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 0aee935c..912a8547 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -126,6 +126,17 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { let media = blip.and_then(|b| media_ref(imp, part, b)); let media = media?; let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() }; + if let Some(layer) = blip.and_then(|b| b.child("extLst")).and_then(|x| x.find("imgLayer")) + && let Some(effect) = layer + .elements() + .filter(|e| e.local() == "imgEffect") + .flat_map(|e| e.elements()) + .map(|e| e.local().to_string()) + .find(|n| n.starts_with("artistic")) + && let Some(original) = media_ref(imp, part, layer) + { + pf.artistic = Some(deckcraft_model::style::Artistic { effect, original }); + } if let Some(b) = blip { for e in b.elements() { match e.local() { @@ -320,6 +331,7 @@ pub fn body_pr(b: &El) -> BodyProps { col_spacing: pt(b, "spcCol"), rtl_col: b.bool("rtlCol"), upright: b.bool("upright"), + space_first_last: b.bool("spcFirstLastPara"), ..Default::default() }; for e in b.elements() { @@ -431,7 +443,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option (lvl, pp, def) } -pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option { +pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option { let rid = h.attr("r:id").unwrap_or(""); let rel = part.rels.get(rid); let action = h.attr("action").unwrap_or(""); @@ -450,7 +462,8 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option { Action::Slide { slide } } else if let Some(q) = action.strip_prefix("ppaction://customshow?") { let id = q.split('&').find_map(|kv| kv.strip_prefix("id=")).unwrap_or("0"); - Action::CustomShow { name: imp.custom_show_names.get(id).cloned().unwrap_or_else(|| id.to_string()) } + let returns = !matches!(q.split('&').find_map(|kv| kv.strip_prefix("return=")), Some("false" | "0")); + Action::CustomShow { name: imp.custom_show_names.get(id).cloned().unwrap_or_else(|| id.to_string()), returns } } else if action.starts_with("ppaction://program") { Action::Program { path: rel.map(|r| r.target.clone()).unwrap_or_default() } } else if action.starts_with("ppaction://media") { @@ -470,7 +483,14 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option { } else { return None; }; - Some(Hyperlink { action: a, tooltip: h.attr("tooltip").unwrap_or("").to_string(), highlight_click: h.bool("highlightClick").unwrap_or(false) }) + Some(Hyperlink { + action: a, + tooltip: h.attr("tooltip").unwrap_or("").to_string(), + highlight_click: h.bool("highlightClick").unwrap_or(false), + stop_sound: h.bool("endSnd").unwrap_or(false), + sound: h.child("snd").and_then(|s| media_ref(imp, part, s)), + zoom_return: None, + }) } pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { @@ -503,7 +523,10 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { "effectLst" => { let fx = effects(e); p.shadow = fx.outer_shadow; + p.inner_shadow = fx.inner_shadow; p.glow = fx.glow; + p.reflection = fx.reflection; + p.soft_edge = fx.soft_edge; } "highlight" => p.highlight = color(e), "uFill" => p.underline_color = e.child("solidFill").and_then(color), @@ -512,6 +535,7 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { "cs" => p.font_cs = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from), "sym" => p.font_sym = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from), "hlinkClick" => p.link = hyperlink(imp, part, e), + "hlinkMouseOver" => p.hover = hyperlink(imp, part, e), _ => {} } } @@ -545,15 +569,18 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph { for e in p.elements() { match e.local() { "pPr" => { - let (lvl, pp, _) = ppr(imp, part, e); + let (lvl, pp, def) = ppr(imp, part, e); para.level = lvl.unwrap_or(0); para.props = pp; + if let Some(d) = def { + para.def_props = d; + } } "r" => { let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default(); let text: String = e.child("t").map(|t| t.text()).unwrap_or_default(); - // Vertical tab inside text is a soft break in some writers. - for (i, piece) in text.split('\u{b}').enumerate() { + // A vertical tab or line feed inside text is a line break, as it lays out and as export writes it. + for (i, piece) in text.split(['\u{b}', '\n']).enumerate() { if i > 0 { para.runs.push(Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); } @@ -579,7 +606,7 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph { for t in ts { text.push_str(&t.text()); } - let omml = e.elements().map(|c| c.to_xml()).collect::(); + let omml = e.elements().map(|c| part.keep_inner(c)).collect::(); para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } }); } "endParaRPr" => para.end_props = rpr(imp, part, e), @@ -619,6 +646,9 @@ mod tests { assert_eq!(b.vert, Some(TextDir::Vertical270)); assert_eq!(b.columns, Some(2)); assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 })); + assert_eq!(b.space_first_last, None); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d.root).space_first_last, Some(true)); // mongolianVert is its own direction (PowerPoint runs its lines left to right) and is // written back as it was read. let d = parse(br#""#).unwrap(); diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..ac83dfc0 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -1,12 +1,14 @@ //! PPTX import: package → [`Presentation`]. -mod chart; +pub(crate) mod chart; +mod chartex; pub(crate) mod dml; mod shapes; mod theme; mod timing; pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml}; +pub(crate) use chartex::{chartex_from, read_chartex_xml}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -14,8 +16,8 @@ use std::sync::Arc; use deckcraft_geom::{Size, emu_to_pt}; use deckcraft_model::text::TextBody; use deckcraft_model::{ - Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation, - Properties, Section, ShowSettings, Slide, SlideId, defaults, + Background, Comment, CustomShow, EmbeddedFont, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, + Presentation, Properties, Section, ShowSettings, Slide, SlideId, defaults, }; use crate::PptxError; @@ -32,6 +34,8 @@ pub struct Imp<'a> { pub custom_show_names: HashMap, /// GUIDs (upper case) of the table styles the file defines. pub table_style_ids: Vec, + /// Per legacy VML drawing part: shape id → preview picture part (see `shapes::vml_preview`). + pub vml_previews: HashMap>, } impl Imp<'_> { @@ -71,16 +75,52 @@ pub struct Part { pub name: String, pub rels: Rels, pub ns: Vec<(String, String)>, + /// Prefixes bound below the root only (on a wrapper such as `mc:Choice`), each to one namespace. + pub inner_ns: Vec<(String, String)>, } impl Part { pub fn new(pkg: &Package, name: &str, doc: &Doc) -> Self { - Part { name: name.to_string(), rels: pkg.rels(name), ns: doc.ns_decls() } + let ns = doc.ns_decls(); + Part { name: name.to_string(), rels: pkg.rels(name), inner_ns: inner_decls(&doc.root, &ns), ns } } /// Serialize an element with the namespace declarations it needs from the part root. pub fn keep(&self, el: &El) -> String { keep_xml(el, &self.ns) } + /// Like [`Part::keep`], also declaring prefixes that only an enclosing wrapper binds. + pub fn keep_inner(&self, el: &El) -> String { + let ns: Vec<(String, String)> = self.ns.iter().chain(&self.inner_ns).cloned().collect(); + keep_xml(el, &ns) + } +} + +/// Prefixes declared below the root but not on it, skipping any bound to different namespaces. +fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> { + fn rec(e: &El, out: &mut Vec<(String, String)>, clash: &mut HashSet, depth: usize) { + if depth > crate::xml::MAX_DEPTH { + return; + } + for (k, v) in e.attrs.iter().filter(|(k, _)| k.starts_with("xmlns:")) { + match out.iter().find(|(o, _)| o == k).map(|(_, u)| u != v) { + Some(true) => { + clash.insert(k.clone()); + } + Some(false) => {} + None => out.push((k.clone(), v.clone())), + } + } + for c in e.elements() { + rec(c, out, clash, depth + 1); + } + } + let mut out = vec![]; + let mut clash = HashSet::new(); + for c in root.elements() { + rec(c, &mut out, &mut clash, 1); + } + out.retain(|(k, _)| !clash.contains(k) && !ns.iter().any(|(r, _)| r == k)); + out } /// Serialize `el`, declaring every prefix it uses that the source root declared. @@ -117,10 +157,130 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { } /// Namespace prefixes whose `mc:Choice` content we understand. -const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"]; +const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"]; + +/// Office 2016 charts sit in a `cx1` choice; take it only for a layout DeckCraft reads (the +/// fallback is a picture or "this chart isn't available" text). The part is checked later, when +/// the frame is read, so here only the namespace counts. +fn chartex_readable(ch: &El) -> bool { + let mut found = vec![]; + ch.find_all("graphicData", &mut found); + found.iter().any(|g| g.attr("uri").is_some_and(|u| u.ends_with("/chartex"))) +} + +/// A relationship to an embedded 3-D model (`.glb`). +pub(crate) fn is_model3d(rel: &opc::Rel) -> bool { + !rel.external && (rel.kind == "model3d" || rel.target.to_ascii_lowercase().ends_with(".glb")) +} + +fn choice_has_model3d(el: &El, rels: &Rels) -> bool { + fn rec(el: &El, rels: &Rels, depth: usize) -> bool { + if depth > 16 { + return false; + } + if el.is("contentPart") + && let Some(id) = el.attr("r:id").filter(|s| !s.is_empty()) + && rels.get(id).is_some_and(is_model3d) + { + return true; + } + el.elements().any(|c| rec(c, rels, depth + 1)) + } + rec(el, rels, 0) +} + +/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part. +pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> { + let Some(list) = holder.child("custDataLst") else { return vec![] }; + let mut out = Vec::new(); + for t in list.children_named("tags").take(100) { + let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue }; + for tag in doc.root.children_named("tag").take(10_000) { + if let Some(name) = tag.attr("name") { + out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string())); + } + } + } + out +} + +/// Relationship types whose parts are kept verbatim, and whether they hang off the package root. +const KEPT_RELS: [(&str, bool); 3] = [ + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", false), + ("http://schemas.microsoft.com/office/2020/02/relationships/classificationlabels", true), + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties", true), +]; + +/// A part we may keep: outside `ppt/` and the properties the writer makes itself. +fn keepable(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && n != "docprops/core.xml" + && n != "docprops/app.xml" + && !n.starts_with("[content_types]") +} + +/// Custom XML data, sensitivity labels and custom properties, with the parts they relate to +/// (`itemProps`) and their relationships parts, as written. +fn kept_parts(pkg: &Package, pres_path: &str) -> Vec { + use deckcraft_model::KeptPart; + let mut out: Vec = Vec::new(); + let text = |name: &str| pkg.get(name).and_then(|b| std::str::from_utf8(b).ok()).map(str::to_string); + for (holder, from_root) in [("", true), (pres_path, false)] { + let Some(doc) = pkg.xml(&opc::rels_path(holder)) else { continue }; + let base = opc::dir_of(holder); + for r in doc.root.children_named("Relationship") { + let (Some(ty), Some(target)) = (r.attr("Type"), r.attr("Target")) else { continue }; + if r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")) + || !KEPT_RELS.iter().any(|(t, root)| *t == ty && *root == from_root) + { + continue; + } + let name = opc::resolve(&base, target); + if !keepable(&name) || out.iter().any(|k| k.name.eq_ignore_ascii_case(&name)) || out.len() >= 400 { + continue; + } + let Some(xml) = text(&name) else { continue }; + out.push(KeptPart { + content_type: pkg.content_type(&name).unwrap_or_default(), + name: name.clone(), + xml, + rel_type: ty.to_string(), + from_root, + }); + // The part's own relationships (customXml item → itemProps) and their targets. + let rp = opc::rels_path(&name); + if let Some(rels_xml) = text(&rp) { + for sub in pkg.rels(&name).items.iter().filter(|s| !s.external && keepable(&s.target)) { + if let Some(x) = text(&sub.target) + && !out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target)) + { + out.push(KeptPart { + content_type: pkg.content_type(&sub.target).unwrap_or_default(), + name: sub.target.clone(), + xml: x, + ..Default::default() + }); + } + } + out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() }); + } + } + } + out +} /// Replace every `mc:AlternateContent` by the children of the branch we can read best. +/// A 3-D model choice is left in place (even when its `Requires` prefix is not one we otherwise +/// understand) so the shape reader can keep the model and its fallback picture. Ink content parts +/// still fall through to the fallback picture. pub fn resolve_mc(el: &mut El, depth: usize) { + resolve_mc_rels(el, depth, None); +} + +fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) { if depth > crate::xml::MAX_DEPTH { return; } @@ -129,10 +289,15 @@ pub fn resolve_mc(el: &mut El, depth: usize) { for c in std::mem::take(&mut el.children) { match c { Node::El(ac) if ac.is("AlternateContent") => { + if rels.is_some_and(|rels| ac.children_named("Choice").any(|ch| choice_has_model3d(ch, rels))) { + out.push(Node::El(ac)); + continue; + } let choice = ac.children_named("Choice").find(|ch| { let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r)); // Ink content parts are not modelled; prefer their fallback picture. - let modelled = !ch.elements().any(|e| e.is("contentPart")); + let modelled = !ch.elements().any(|e| e.is("contentPart")) + && (!ch.attr("Requires").unwrap_or("").contains("cx1") || chartex_readable(ch)); req_ok && modelled }); let branch = choice.or_else(|| ac.child("Fallback")); @@ -157,13 +322,14 @@ pub fn resolve_mc(el: &mut El, depth: usize) { el.children = out; } for c in el.elements_mut() { - resolve_mc(c, depth + 1); + resolve_mc_rels(c, depth + 1, rels); } } fn load(pkg: &Package, name: &str) -> Option { let mut d = pkg.xml(name)?; - resolve_mc(&mut d.root, 0); + let rels = pkg.rels(name); + resolve_mc_rels(&mut d.root, 0, Some(&rels)); Some(d) } @@ -179,6 +345,47 @@ fn max_cnvpr(el: &El, m: &mut u32) { } } +/// The theme of a `.thmx` theme file (its `officeDocument` is a theme part), or of the first +/// master of a `.pptx` / `.potx`, with the media its format scheme uses (ids from 1). +pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec), PptxError> { + let pkg = Package::open(bytes)?; + let target = pkg.rels("").first_of("officeDocument").map(|r| r.target.clone()); + let theme_path = match &target { + Some(t) if pkg.xml(t).is_some_and(|d| d.root.is("theme")) => t.clone(), + _ => { + // A presentation: its first master's theme. + let pres = target.unwrap_or_else(|| "ppt/presentation.xml".into()); + let pres_doc = load(&pkg, &pres).ok_or_else(|| PptxError::NotPptx("neither a theme nor a presentation".into()))?; + let pres_part = Part::new(&pkg, &pres, &pres_doc); + let master = pres_doc + .root + .child("sldMasterIdLst") + .and_then(|l| l.children_named("sldMasterId").next()) + .and_then(|m| pres_part.rels.target(m.attr("r:id").unwrap_or("")).map(String::from)) + .or_else(|| pres_part.rels.all_of("slideMaster").next().map(|r| r.target.clone())) + .ok_or_else(|| PptxError::NotPptx("the presentation has no slide master".into()))?; + pkg.rels(&master).first_of("theme").map(|r| r.target.clone()).ok_or_else(|| PptxError::NotPptx("the slide master has no theme".into()))? + } + }; + let doc = load(&pkg, &theme_path).ok_or_else(|| PptxError::NotPptx(format!("missing or unreadable {theme_path}")))?; + if !doc.root.is("theme") { + return Err(PptxError::NotPptx(format!("{theme_path} is not a theme part"))); + } + let mut imp = Imp { + pkg: &pkg, + media: vec![], + media_by_part: HashMap::new(), + next: 0, + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + vml_previews: HashMap::new(), + }; + let part = Part::new(&pkg, &theme_path, &doc); + let theme = theme::read_theme(&mut imp, &part, &doc.root); + Ok((theme, imp.media)) +} + pub fn import(bytes: &[u8]) -> Result { let pkg = Package::open(bytes)?; let root_rels = pkg.rels(""); @@ -233,6 +440,7 @@ pub fn import(bytes: &[u8]) -> Result { slide_by_part: HashMap::new(), custom_show_names: HashMap::new(), table_style_ids: Vec::new(), + vml_previews: HashMap::new(), }; // Slide ids first so hyperlinks anywhere can point at slides. let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; @@ -312,12 +520,24 @@ pub fn import(bytes: &[u8]) -> Result { let np = Part::new(&pkg, &n.target, &nd); slide.notes = read_notes(&mut imp, &np, &nd.root); } - // Comments. + // Comments. A modern part and the legacy part can describe the same threads (PowerPoint + // writes both): the modern one wins, its flattened legacy copy is skipped. + let mut parts: Vec<(Doc, bool)> = vec![]; for c in part.rels.all_of("comments") { if let Some(cd) = pkg.xml(&c.target) { - slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root)); + // The content type names the modern part; a package that types it only through a + // Default entry instead of an Override is still modern when its root is p188. + let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; + parts.push((cd, modern)); } } + let has_modern = parts.iter().any(|(_, m)| *m); + for (cd, modern) in parts { + if has_modern && !modern { + continue; + } + slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root, modern)); + } file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } @@ -342,6 +562,7 @@ pub fn import(bytes: &[u8]) -> Result { p.raw_ext = Some(kept); } } + p.tags = tags(&imp, &pres_part, pres_el); // Custom shows. if let Some(l) = pres_el.child("custShowLst") { for cs in l.children_named("custShow") { @@ -357,6 +578,20 @@ pub fn import(bytes: &[u8]) -> Result { p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); } } + // Drawing guides (eighths of a point). + if let Some(r) = pres_part.rels.first_of("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) + { + p.guides = lst + .children_named("guide") + .filter_map(|g| { + let pos = g.attr("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) @@ -377,14 +612,50 @@ pub fn import(bytes: &[u8]) -> Result { let tp = Part::new(&pkg, &t.target, &td); m.theme = theme::read_theme(&mut imp, &tp, &td.root); } - if let Some(tree) = d.root.path(&["cSld", "spTree"]) { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); + if let Some(csld) = d.root.child("cSld") { + m.background = background(&mut imp, &part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); + } } if let Some(cm) = d.root.child("clrMap") { m.color_map = color_map(cm); } + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child("notesStyle") { + m.body_style = dml::list_style(&mut imp, &part, ns); + } p.notes_master = Some(Arc::new(m)); } + // Handout master (header, footer, date and slide number for printed handouts). + if let Some(r) = pres_part.rels.first_of("handoutMaster") + && let Some(d) = load(&pkg, &r.target) + { + let part = Part::new(&pkg, &r.target, &d); + let mut ctx = shapes::IdCtx::default(); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; + if let Some(t) = part.rels.first_of("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(&pkg, &t.target, &td); + m.theme = theme::read_theme(&mut imp, &tp, &td.root); + } else if let Some(first) = p.masters.first() { + m.theme = first.theme.clone(); + } + if let Some(csld) = d.root.child("cSld") { + if let Some(n) = csld.attr("name").filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + m.background = background(&mut imp, &part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); + } + } + if let Some(cm) = d.root.child("clrMap") { + m.color_map = color_map(cm); + } + p.handout_master = Some(Arc::new(m)); + } // Document properties. if let Some(r) = root_rels.first_of("core-properties") && let Some(d) = pkg.xml(&r.target) @@ -397,39 +668,158 @@ pub fn import(bytes: &[u8]) -> Result { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } + p.kept_parts = kept_parts(&pkg, &pres_path); + inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); p.header_footer = header_footer(&p); + p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); p.media = std::mem::take(&mut imp.media); p.next_id = imp.next; p.fix_next_id(); Ok(p) } +/// Embedded font faces in one file. Past this, further typefaces are dropped. +const MAX_EMBEDDED_FONTS: usize = 64; +/// One font part. A larger part is skipped; the rest of the file is still read. +const MAX_FONT_BYTES: usize = 32 * 1024 * 1024; + +/// `p:embeddedFontLst`: each typeface and the font parts its four face slots point at. +fn read_embedded_fonts(imp: &mut Imp, pres: &Part, root: &El) -> Vec { + let Some(list) = root.child("embeddedFontLst") else { return Vec::new() }; + let mut out = Vec::new(); + for e in list.children_named("embeddedFont").take(MAX_EMBEDDED_FONTS) { + let Some(font) = e.child("font") else { continue }; + let Some(typeface) = font.attr("typeface").map(str::trim).filter(|t| !t.is_empty()) else { continue }; + let typeface: String = typeface.chars().take(255).collect(); + let slot = |imp: &mut Imp, name: &str| -> Option { + let id = e.child(name)?.attr("r:id")?; + let part = pres.rels.target(id)?; + let data = imp.pkg.get(part)?; + if data.is_empty() || data.len() > MAX_FONT_BYTES { + log::warn!("pptx: embedded font {part} skipped ({} bytes)", data.len()); + return None; + } + imp.media_for(part) + }; + let regular = slot(imp, "regular"); + let bold = slot(imp, "bold"); + let italic = slot(imp, "italic"); + let bold_italic = slot(imp, "boldItalic"); + if regular.is_none() && bold.is_none() && italic.is_none() && bold_italic.is_none() { + continue; + } + out.push(EmbeddedFont { + typeface, + panose: panose_attr(font), + pitch_family: byte_attr(font, "pitchFamily"), + charset: byte_attr(font, "charset"), + regular, + bold, + italic, + bold_italic, + }); + } + out +} + +fn byte_attr(el: &El, name: &str) -> Option { + u8::try_from(el.u32(name)?).ok() +} + +/// `panose` is 20 hex digits (ST_Panose). Anything else is dropped. +fn panose_attr(el: &El) -> Option { + let v = el.attr("panose")?.trim(); + (v.len() == 20 && v.chars().all(|c| c.is_ascii_hexdigit())).then(|| v.to_string()) +} + fn sane_len(v: f64, dflt: f64) -> f64 { if v.is_finite() && (1.0..=20_000.0).contains(&v) { v } else { dflt } } /// Does the element (or a descendant) reference a relationship? pub fn has_rel_refs(el: &El) -> bool { - fn rec(e: &El, depth: usize) -> bool { + !rel_refs(el).is_empty() +} + +/// Is this attribute a relationship id (`r:id`, `r:embed`, `r:dm`…)? +pub fn is_rel_attr(k: &str, v: &str) -> bool { + !k.starts_with("xmlns") + && k.contains(':') + && matches!(k.rsplit(':').next(), Some("id" | "embed" | "link" | "dm" | "lo" | "qs" | "cs" | "pict" | "href")) + && !v.is_empty() +} + +/// Every relationship id `el` and its descendants use, sorted and without repeats. +pub fn rel_refs(el: &El) -> Vec { + fn rec(e: &El, out: &mut Vec, depth: usize) { if depth > crate::xml::MAX_DEPTH { - return false; + return; + } + out.extend(e.attrs.iter().filter(|(k, v)| is_rel_attr(k, v)).map(|(_, v)| v.clone())); + for c in e.elements() { + rec(c, out, depth + 1); } - let own = e.attrs.iter().any(|(k, v)| { - !k.starts_with("xmlns") - && k.contains(':') - && matches!(k.rsplit(':').next(), Some("id" | "embed" | "link" | "dm" | "lo" | "qs" | "cs" | "pict" | "href")) - && !v.is_empty() - }); - own || e.elements().any(|c| rec(c, depth + 1)) } - rec(el, 0) + let mut out = vec![]; + rec(el, &mut out, 0); + out.sort(); + out.dedup(); + out } pub fn color_map(cm: &El) -> Vec<(String, String)> { cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect() } +/// The anchor the theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`) give new shapes +/// text, if any. +fn theme_shape_anchor(t: &deckcraft_model::Theme) -> Option { + let raw = t.raw_extra.as_deref()?; + let sp = &raw[raw.find("spDef")?..]; + let sp = &sp[..sp.find("/a:spDef").or_else(|| sp.find("txDef")).unwrap_or(sp.len())]; + let b = &sp[sp.find("bodyPr")?..]; + let b = &b[..b.find('>').unwrap_or(b.len())]; + let v = &b[b.find("anchor=\"")? + 8..]; + deckcraft_model::text::Anchor::from_xml(&v[..v.find('"')?]) +} + +/// A shape's text without an anchor of its own sits at the top, as the standard has it (or where +/// the theme's shape defaults put it). The model's own default for shapes is the middle, so a +/// top or bottom anchor is made explicit here and a middle one left unset (placeholders and text +/// boxes keep inheriting). +fn default_text_anchor(p: &mut Presentation) { + fn fix(shapes: &mut [deckcraft_model::Shape], a: deckcraft_model::text::Anchor) { + walk_shapes_mut(shapes, &mut |s| { + if s.ph.is_none() + && !s.text_box + && !matches!(s.kind, deckcraft_model::ShapeKind::Table(_)) + && let Some(t) = &mut s.text + { + // Middle is the model's own default for shapes, so it is kept as unset. + let got = t.body.anchor.unwrap_or(a); + t.body.anchor = (got != deckcraft_model::text::Anchor::Middle).then_some(got); + } + }); + } + let top = deckcraft_model::text::Anchor::Top; + let anchors: Vec<_> = p.masters.iter().map(|m| (m.id, theme_shape_anchor(&m.theme).unwrap_or(top))).collect(); + for m in &mut p.masters { + let a = anchors.iter().find(|(id, _)| *id == m.id).map(|x| x.1).unwrap_or(top); + let m = Arc::make_mut(m); + fix(&mut m.shapes, a); + for l in &mut m.layouts { + fix(&mut l.shapes, a); + } + } + let per_slide: Vec<_> = + p.slides.iter().map(|s| p.master_for(s).and_then(|(m, _)| anchors.iter().find(|(id, _)| *id == m.id)).map(|x| x.1).unwrap_or(top)).collect(); + for (s, a) in p.slides.iter_mut().zip(per_slide) { + fix(&mut Arc::make_mut(s).shapes, a); + } +} + /// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the /// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a /// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left @@ -707,6 +1097,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout let mut ctx = shapes::IdCtx::default(); if let Some(csld) = root.child("cSld") { s.name = csld.attr("name").unwrap_or("").to_string(); + s.tags = tags(imp, part, csld); s.background = background(imp, part, csld); if let Some(tree) = csld.child("spTree") { s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); @@ -746,7 +1137,7 @@ fn read_notes(imp: &mut Imp, part: &Part, root: &El) -> TextBody { TextBody::default() } -fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec { +fn read_comments(pkg: &Package, pres: &Part, root: &El, modern: bool) -> Vec { // Authors: legacy commentAuthors.xml (numeric ids) and modern authors.xml (GUIDs). let mut authors: HashMap = HashMap::new(); for kind in ["commentAuthors", "authors"] { @@ -760,7 +1151,9 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec { } } } - fn one(e: &El, authors: &HashMap, depth: usize) -> Comment { + // Legacy positions are 1/8 pt; the modern part's p188:pos is an a:CT_Point2D in EMU. + let scale = if modern { 12_700.0 } else { 8.0 }; + fn one(e: &El, authors: &HashMap, depth: usize, scale: f64) -> Comment { let (author, initials) = e.attr("authorId").and_then(|a| authors.get(a).cloned()).unwrap_or_default(); let text = if let Some(t) = e.child("text") { t.text() @@ -771,7 +1164,7 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec { }; let pos = e.child("pos"); let replies = if depth < 4 { - e.child("replyLst").map(|l| l.children_named("reply").take(1000).map(|r| one(r, authors, depth + 1)).collect()).unwrap_or_default() + e.child("replyLst").map(|l| l.children_named("reply").take(1000).map(|r| one(r, authors, depth + 1, scale)).collect()).unwrap_or_default() } else { vec![] }; @@ -780,14 +1173,14 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec { initials, text, date: e.attr("dt").or(e.attr("created")).unwrap_or("").to_string(), - x: pos.and_then(|p| p.f64("x")).unwrap_or(0.0) / 8.0, - y: pos.and_then(|p| p.f64("y")).unwrap_or(0.0) / 8.0, + x: pos.and_then(|p| p.f64("x")).unwrap_or(0.0) / scale, + y: pos.and_then(|p| p.f64("y")).unwrap_or(0.0) / scale, resolved: e.attr("status") == Some("resolved"), replies, shape: None, } } - root.children_named("cm").take(10_000).map(|c| one(c, &authors, 0)).collect() + root.children_named("cm").take(10_000).map(|c| one(c, &authors, 0, scale)).collect() } fn read_show(sp: &El, shows: &HashMap) -> ShowSettings { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..3f7072f4 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -1,5 +1,5 @@ //! Shape trees: `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame` (tables, charts, SmartArt, -//! OLE and other objects). +//! OLE and other objects) and `p:contentPart` (3-D models). use std::collections::{HashMap, HashSet}; @@ -70,10 +70,11 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us "grpSp" => Some(grp(imp, part, ctx, e, depth)), "cxnSp" => Some(cxn(imp, part, ctx, e)), "graphicFrame" => Some(frame(imp, part, ctx, e, depth)), - "contentPart" => { - log::warn!("pptx: ink content part in {} not imported", part.name); + "contentPart" => content_part(imp, part, ctx, e).or_else(|| { + log::warn!("pptx: content part in {} not imported", part.name); None - } + }), + "AlternateContent" => read_model_alternate(imp, part, ctx, e), _ => None, }; if let Some(s) = s { @@ -165,6 +166,7 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { has_custom_prompt: ph.bool("hasCustomPrompt").unwrap_or(false), }); } + s.tags = super::tags(imp, part, e); } _ => {} } @@ -279,6 +281,58 @@ fn ms(v: Option<&str>) -> u32 { v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, u32::MAX as f64) as u32).unwrap_or(0) } +fn find_content_part(el: &El) -> Option<&El> { + fn rec(el: &El, depth: usize) -> Option<&El> { + if depth > 16 { + return None; + } + if el.is("contentPart") { + return Some(el); + } + el.elements().find_map(|c| rec(c, depth + 1)) + } + rec(el, 0) +} + +/// `p:contentPart` whose relationship is a 3-D model. Other content parts (ink) are left out. +fn content_part(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + let id = e.attr("r:id").filter(|s| !s.is_empty())?; + let rel = part.rels.get(id)?; + if !super::is_model3d(rel) { + return None; + } + let target = rel.target.clone(); + let media = imp.media_for(&target).or_else(|| { + log::warn!("pptx: 3-D model part missing in {}", part.name); + None + })?; + let mut s = base(imp, part, ctx, e.child("nvContentPartPr")); + if let Some(x) = e.child("xfrm") { + s.xfrm = Some(dml::xfrm(x).0); + } + s.kind = ShapeKind::Model3d { media, poster: None }; + Some(s) +} + +/// A 3-D model wrapped in `mc:AlternateContent`: the model choice, plus the fallback picture. +/// If the model part is missing, the fallback picture is the shape. +fn read_model_alternate(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + if let Some(content) = e.children_named("Choice").find_map(find_content_part) + && let Some(mut s) = content_part(imp, part, ctx, content) + { + let poster = e.child("Fallback").and_then(|fb| { + let mut pics = vec![]; + fb.find_all("blip", &mut pics); + pics.into_iter().find_map(|b| dml::media_ref(imp, part, b)) + }); + if let ShapeKind::Model3d { media, .. } = s.kind { + s.kind = ShapeKind::Model3d { media, poster }; + } + return Some(s); + } + e.child("Fallback").and_then(|fb| fb.elements().find(|c| c.is("pic")).map(|p| pic(imp, part, ctx, p))) +} + fn pic(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { let mut s = base(imp, part, ctx, e.child("nvPicPr")); if let Some(pr) = e.child("spPr") { @@ -367,9 +421,10 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S } if let Some(c) = gd.child("chart") && let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or("")) - && let Some(bytes) = imp.pkg.get(&rel.target) - && let Some(chart) = super::read_chart_xml(bytes) + && let Some(bytes) = imp.pkg.xml_bytes(&rel.target) + && let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) } { + chart.kept = super::chart::capture_kept(imp.pkg, &rel.target); s.kind = ShapeKind::Chart(Box::new(chart)); return s; } @@ -395,7 +450,22 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S } else { uri.rsplit('/').next().unwrap_or("Object").to_string() }; - s.kind = ShapeKind::Opaque { xml: part.keep(e), preview, label }; + // Keep the parts it points to so a save writes it back whole (#203). A part with relationships + // of its own, or an external link, can't be carried: the save then falls back to the preview. + let parts = super::rel_refs(e) + .into_iter() + .map(|id| { + let rel = part.rels.get(&id).filter(|r| !r.external)?; + if !imp.pkg.rels(&rel.target).items.is_empty() { + return None; + } + let (kind, target) = (rel.ty.clone(), rel.target.clone()); + let media = if rel.kind == "diagramData" { diagram_data(imp, &target) } else { imp.media_for(&target) }; + Some((id, kind, media?)) + }) + .collect::>>() + .unwrap_or_default(); + s.kind = ShapeKind::Opaque { xml: part.keep(e), preview, label, parts }; s } @@ -403,17 +473,67 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S /// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them. fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option { for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) { - let Some(doc) = imp.pkg.xml(&rel.target) else { continue }; - let mut shapes = vec![]; - doc.root.find_all("shape", &mut shapes); - let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue }; - let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue }; - let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?; + let key = crate::opc::norm(&rel.target); + if !imp.vml_previews.contains_key(&key) { + let index = vml_index(imp.pkg, &rel.target); + imp.vml_previews.insert(key.clone(), index); + } + let Some(target) = imp.vml_previews.get(&key).and_then(|m| m.get(spid)).cloned() else { continue }; return imp.media_for(&target); } None } +/// Every shape id in a VML drawing with a picture, mapped to that picture's part: parsed once per +/// drawing however many objects look it up. +fn vml_index(pkg: &crate::opc::Package, vml: &str) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Some(doc) = pkg.xml(vml) else { return out }; + let rels = pkg.rels(vml); + let mut shapes = vec![]; + doc.root.find_all("shape", &mut shapes); + for s in shapes { + let Some(id) = s.attr("id") else { continue }; + if out.contains_key(id) { + continue; // the first shape with an id wins, as the old lookup did + } + let Some(img) = s.find("imagedata") else { continue }; + let Some(rid) = img.attr("o:relid").or(img.attr("r:id")) else { continue }; + if let Some(r) = rels.get(rid).filter(|r| !r.external) { + out.insert(id.to_string(), r.target.clone()); + } + } + out +} + +/// A diagram's data part, without the link to its cached drawing: that link is a slide +/// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get +/// here, and those are drawn from their data. +fn diagram_data(imp: &mut Imp, target: &str) -> Option { + fn strip(e: &mut El, depth: usize) { + if depth > crate::xml::MAX_DEPTH { + return; + } + e.children.retain(|n| !matches!(n, crate::xml::Node::El(c) if c.is("ext") && c.find("dataModelExt").is_some())); + for c in e.elements_mut() { + strip(c, depth + 1); + } + } + let mut doc = imp.pkg.xml(target)?; + strip(&mut doc.root, 0); + let xml = format!("\n{}", doc.root.to_xml()); + let id = deckcraft_model::MediaId(imp.alloc()); + let content_type = imp.pkg.content_type(target)?; + imp.media.push(deckcraft_model::MediaItem { + id, + name: crate::opc::file_of(target).to_string(), + content_type, + data: std::sync::Arc::new(xml.into_bytes()), + link: None, + }); + Some(id) +} + /// SmartArt: read the pre-drawn shapes of its drawing part as a group. fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option { let rel_ids = gd.child("relIds")?; @@ -429,12 +549,175 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> if children.is_empty() { return None; } + let children = smartart_text_rects(imp, &mut sub, tree, children, depth + 1); let mut g = frame.clone(); let x = frame.xfrm.unwrap_or_default(); g.kind = ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, x.w, x.h) }; + g.diagram = diagram_outline(imp, part, rel_ids, &data.root); + // Node pictures, from the blips of the data model's presentation points. + if let Some(d) = &mut g.diagram { + let rels = imp.pkg.rels(&dm.target); + for (k, rid) in crate::dgm::node_pictures(&data.root) { + let Some(target) = rels.get(&rid).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; + let media = imp.media_for(&target); + if let Some(n) = d.nodes.get_mut(k) { + n.picture = media; + } + } + } Some(g) } +/// A SmartArt drawing shape (`dsp:sp`) keeps its text rectangle in `dsp:txXfrm`, apart from the +/// shape's own box: the text of a narrow shape turned by 90° is often laid out across it. Put each +/// shape's text on that rectangle. `shapes` are what [`sp_tree`] read from `tree`, in order. +fn smartart_text_rects(imp: &mut Imp, ctx: &mut IdCtx, tree: &El, shapes: Vec, depth: usize) -> Vec { + if depth > MAX_TREE_DEPTH { + return shapes; + } + let mut out = Vec::with_capacity(shapes.len()); + let mut shapes = shapes.into_iter(); + // The same elements `sp_tree` turns into shapes. + for e in tree.elements().filter(|e| matches!(e.local(), "sp" | "pic" | "grpSp" | "cxnSp" | "graphicFrame")) { + let Some(mut s) = shapes.next() else { break }; + let mut text_box = None; + match (e.local(), &mut s.kind) { + ("grpSp", ShapeKind::Group { children, .. }) => { + let kids = std::mem::take(children); + *children = smartart_text_rects(imp, ctx, e, kids, depth + 1); + } + ("sp", ShapeKind::Shape) => { + if let Some(t) = e.child("txXfrm") { + text_box = text_on_rect(imp, ctx, &mut s, dml::xfrm(t).0); + } + } + _ => {} + } + out.push(s); + out.extend(text_box); + } + out.extend(shapes); + out +} + +/// Lay out the text of `s` on `t`, a text rectangle in the same space as the shape's box, turned by +/// the shape's rotation plus its own. When that is the shape's text area shrunk by some margin, the +/// shape keeps its text with wider insets. Otherwise (a rectangle reaching outside the shape, or +/// turned against it) the text moves to a new frameless text shape over `s`, which is returned: +/// both export as plain DrawingML that any reader lays out the same way. +fn text_on_rect(imp: &mut Imp, ctx: &mut IdCtx, s: &mut Shape, t: Xfrm) -> Option { + const EPS: f64 = 0.01; + let x = s.xfrm?; + if !x.is_finite() || !t.is_finite() || t.w <= EPS || t.h <= EPS { + return None; + } + let body = s.text.as_mut().filter(|b| !b.is_empty())?; + let turn = |a: f64| a.rem_euclid(360.0); + let upright = |a: f64| turn(a + EPS) < 2.0 * EPS; + let bp = &mut body.body; + if !x.flip_h + && !x.flip_v + && upright(t.rot) + && bp.rot.is_none_or(upright) + && bp.vert.is_none_or(|v| v == TextDir::Horizontal) + && bp.upright != Some(true) + { + // Same orientation as the shape: express the rectangle as insets from the shape's text area. + let c = x.affine().inverse() * t.center(); + let (l, top) = (c.x - t.w / 2.0, c.y - t.h / 2.0); + let (r, b) = (l + t.w, top + t.h); + let area = text_area(&s.geom, x.w, x.h); + let extra = [l - area.x0, top - area.y0, area.x1 - r, area.y1 - b]; + if extra.iter().all(|v| v.is_finite() && *v >= -EPS) { + let [el, et, er, eb] = extra.map(|v| v.max(0.0)); + bp.inset_l = Some(bp.inset_l.unwrap_or(7.2) + el); + bp.inset_t = Some(bp.inset_t.unwrap_or(3.6) + et); + bp.inset_r = Some(bp.inset_r.unwrap_or(7.2) + er); + bp.inset_b = Some(bp.inset_b.unwrap_or(3.6) + eb); + return None; + } + } + if ctx.count >= MAX_SHAPES { + return None; + } + ctx.count += 1; + let text = s.text.take(); + // No fill, outline or effects; the style keeps only the text colour and font. + let style = s.style.clone().map(|mut st| { + st.fill_ref.0 = 0; + st.line_ref.0 = 0; + st.effect_ref.0 = 0; + st + }); + Some(Shape { + id: ctx.assign(imp, None), + name: s.name.clone(), + xfrm: Some(Xfrm { rot: turn(x.rot + t.rot), flip_h: false, flip_v: false, ..t }), + fill: Some(Fill::None), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + style, + text, + click: s.click.clone(), + hover: s.hover.clone(), + ..Default::default() + }) +} + +/// Where a shape's geometry puts its text, in shape-local points (as the renderer computes it). +fn text_area(geom: &Geom, w: f64, h: f64) -> deckcraft_geom::Rect { + let full = deckcraft_geom::Rect::new(0.0, 0.0, w, h); + match geom { + Geom::Preset { name, adj } => { + preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).map(|g| g.text_rect).unwrap_or(full) + } + Geom::Custom { .. } => full, + } +} + +/// The outline of a SmartArt graphic (for the Text Pane), with its layout, quick style and colour +/// parts kept to be written back. +fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option { + let nodes = crate::dgm::outline(data); + if nodes.is_empty() { + return None; + } + let raw = |attr: &str| -> Option { + let rel = part.rels.get(rel_ids.attr(attr)?)?; + let bytes = imp.pkg.get(&rel.target)?; + (bytes.len() <= 4 << 20).then(|| String::from_utf8(bytes.to_vec()).ok()).flatten() + }; + let layout_xml = raw("r:lo"); + let (layout_id, cat) = layout_xml + .as_deref() + .and_then(|x| crate::xml::parse(x.as_bytes()).ok()) + .map(|d| { + let cat = d.root.child("catLst").and_then(|c| c.child("cat")).and_then(|c| c.attr("type")).unwrap_or("").to_string(); + (d.root.attr("uniqueId").unwrap_or("").to_string(), cat) + }) + .unwrap_or_default(); + let lo_type = data + .path(&["ptLst"]) + .and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))) + .and_then(|p| p.child("prSet")) + .and_then(|p| p.attr("loTypeId")) + .unwrap_or(""); + let id = if layout_id.is_empty() { lo_type.to_string() } else { layout_id }; + let pr = data.path(&["ptLst"]).and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))).and_then(|p| p.child("prSet")); + let style = crate::dgm::style_for(pr.and_then(|p| p.attr("qsTypeId")).unwrap_or("")).to_string(); + let colors = crate::dgm::colors_for(pr.and_then(|p| p.attr("csTypeId")).unwrap_or("")); + Some(deckcraft_model::Diagram { + kind: crate::dgm::kind_for(&id, &cat).to_string(), + nodes, + style, + colors, + imported: true, + layout_id: id, + layout_xml, + style_xml: raw("r:qs"), + colors_xml: raw("r:cs"), + }) +} + fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { // A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table // Grid"), not without lines. diff --git a/crates/pptx/src/read/theme.rs b/crates/pptx/src/read/theme.rs index 7d6e614c..a94c6b0b 100644 --- a/crates/pptx/src/read/theme.rs +++ b/crates/pptx/src/read/theme.rs @@ -60,6 +60,9 @@ pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme { .take(16) .map(|e| { let mut fx = e.child("effectLst").map(dml::effects).unwrap_or_default(); + if let Some(b) = e.path(&["sp3d", "bevelT"]) { + fx.bevel = Some(b.attr("prst").unwrap_or("circle").to_string()); + } let raw: String = e.elements().filter(|c| c.is("scene3d") || c.is("sp3d")).map(|c| part.keep(c)).collect(); if !raw.is_empty() { fx.raw3d = Some(raw); diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..33e79a26 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -33,16 +33,18 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option { }, } let default_dur = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == tr.kind).map(|x| x.3).unwrap_or(1000); + // `spd` defaults to "fast" in the schema; PowerPoint omits both attributes for 0.5 s. tr.duration_ms = ms_attr(t, "dur").unwrap_or(match t.attr("spd") { - Some("fast") => 500, Some("med") => 750, Some("slow") => default_dur.max(1000), - _ => default_dur, + _ => 500, }); if let Some(snd) = t.path(&["sndAc", "stSnd", "snd"]) { tr.sound = dml::media_ref(imp, part, snd); + tr.sound_loop = t.path(&["sndAc", "stSnd"]).and_then(|s| s.attr("loop")).is_some_and(|v| v == "1" || v == "true"); } - if tr.kind == "none" && tr.advance_on_click && tr.advance_after_ms.is_none() && tr.sound.is_none() { + tr.stop_sound = t.path(&["sndAc", "endSnd"]).is_some(); + if tr.kind == "none" && tr.advance_on_click && tr.advance_after_ms.is_none() && tr.sound.is_none() && !tr.stop_sound { return None; } Some(tr) @@ -119,6 +121,12 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: let Some(shape) = ctx.lookup(file_id) else { continue }; let paragraph = tgt.path(&["txEl", "pRg"]).and_then(|p| p.u32("st")); let effect = maps::effect_for_preset(class, preset).unwrap_or_else(|| maps::fallback_effect(class)); + // PowerPoint writes Float Down as its own preset (47), not as Float with a direction. + let (effect, option) = match (class, preset) { + (AnimClass::Entrance, 47) => ("float", "d".to_string()), + (AnimClass::Exit, 47) => ("floatOut", "d".to_string()), + _ => (effect, maps::option_from_subtype(effect, sub)), + }; let start = match c.attr("nodeType") { Some("clickEffect") => AnimStart::OnClick, Some("afterEffect") => AnimStart::AfterPrevious, @@ -128,7 +136,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: shape, class, effect: effect.to_string(), - option: maps::option_from_subtype(effect, sub), + option, start, duration_ms: max_dur(c, 0).unwrap_or(0), delay_ms: c.path(&["stCondLst", "cond"]).and_then(|d| ms_attr(d, "delay")).unwrap_or(0), @@ -137,8 +145,11 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: preset_id: Some(preset), preset_subtype: Some(sub), auto_reverse: c.bool("autoRev").unwrap_or(false), + // PowerPoint's "Rewind when done playing" is `fill="remove"` on the effect node. + rewind: c.attr("fill") == Some("remove"), smooth_start: c.f64("accel").map(|v| v / 100_000.0).unwrap_or(0.0), smooth_end: c.f64("decel").map(|v| v / 100_000.0).unwrap_or(0.0), + bounce_end: c.f64("bounceEnd").map(|v| (v / 100_000.0).clamp(0.0, 1.0)).unwrap_or(0.0), ..Default::default() }; a.repeat = match c.attr("repeatCount") { diff --git a/crates/pptx/src/tables.rs b/crates/pptx/src/tables.rs index 24b011c3..67d25d62 100644 --- a/crates/pptx/src/tables.rs +++ b/crates/pptx/src/tables.rs @@ -229,6 +229,16 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt let name = names.iter().find(|(id, _)| id == s).map(|(_, n)| format!("DeckCraft {n}")).unwrap_or_else(|| s.clone()); style_def(&mut w, s, &name); } + // Styles the file defined but no table uses still belong to the deck. + for d in defs { + if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) { + continue; + } + if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) { + done.push(&d.id); + w.raw(&d.raw); + } + } w.close("a:tblStyleLst"); w.finish() } diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..88917450 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -1,9 +1,13 @@ -//! Chart parts: a `c:chartSpace` with cached categories and values (no embedded workbook). +//! Chart parts: a `c:chartSpace` with cached categories and values. +//! An unchanged chart also keeps the embedded workbook and the other parts it points at. -use deckcraft_model::chart::{Chart, ChartType, Series}; +use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; + +/// The Office 2016 chart namespace. +pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; use crate::xml::{A, W}; /// Column letter for a 0-based index (A, B, … Z, AA…). @@ -108,6 +112,20 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option]) { w.close("c:numRef"); } +/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them. +fn num_lit(w: &mut W, vals: &[Option]) { + w.open0("c:numLit"); + w.val("c:ptCount", vals.len()); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.open("c:pt", A::new().a("idx", i)); + w.elt("c:v", &fmt_num(v)); + w.close("c:pt"); + } + } + w.close("c:numLit"); +} + #[derive(Clone, Copy, PartialEq)] enum Group { Bar, @@ -118,11 +136,13 @@ enum Group { Scatter, Radar, Bubble, + Stock, } fn group_of(k: ChartType) -> Group { match k { - ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine | ChartType::Stock => Group::Line, + ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line, + ChartType::Stock => Group::Stock, ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area, ChartType::Pie => Group::Pie, ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut, @@ -140,7 +160,32 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.open0("c:tx"); str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name)); w.close("c:tx"); - sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + if g == Group::Stock && s.line.is_none() { + // A stock chart's series are points joined by the high-low lines, not lines of their own. + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 19050)); + w.empty0("a:noFill"); + w.close("a:ln"); + w.close("c:spPr"); + } else { + sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + } + if g == Group::Stock { + // High-low-close: the close is a dash on the high-low line; with up/down bars (or a set + // marker) the series keep their own. + let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3; + let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close { + "dash" + } else { + "none" + }); + w.open0("c:marker"); + w.val("c:symbol", sym); + if sym != "none" { + w.val("c:size", 7); + } + w.close("c:marker"); + } if g == Group::Bar { w.val("c:invertIfNegative", 0); } @@ -174,8 +219,36 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) if let Some(d) = s.data_labels { data_labels(w, d); } + // After labels, before the category and value caches (ECMA-376 series content order). + if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) { + for t in s.trendlines.iter().take(16) { + trendline(w, t); + } + } let n = c.categories.len().max(s.values.len()); let last = n + 1; + if let Some(e) = &s.error_bars { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); + } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; w.open0("c:xVal"); @@ -207,6 +280,51 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.close("c:ser"); } +fn trend_num(v: Option) -> Option { + v.filter(|v| v.is_finite() && v.abs() <= 1.0e12) +} + +fn trend_name(s: &str) -> Option { + let cleaned: String = + s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { None } else { Some(cleaned.to_string()) } +} + +fn trendline(w: &mut W, t: &Trendline) { + w.open0("c:trendline"); + if let Some(name) = t.name.as_deref().and_then(trend_name) { + w.elt("c:name", &name); + } + w.val("c:trendlineType", t.kind.xml()); + if t.kind == TrendlineType::Poly + && let Some(o) = t.order + { + w.val("c:order", o.clamp(2, 6)); + } + if t.kind == TrendlineType::MovingAvg + && let Some(p) = t.period + { + w.val("c:period", p.clamp(2, 255)); + } + if let Some(v) = trend_num(t.forward) { + w.val("c:forward", fmt_num(v)); + } + if let Some(v) = trend_num(t.backward) { + w.val("c:backward", fmt_num(v)); + } + if let Some(v) = trend_num(t.intercept) { + w.val("c:intercept", fmt_num(v)); + } + if t.disp_r_sqr { + w.val("c:dispRSqr", 1); + } + if t.disp_eq { + w.val("c:dispEq", 1); + } + w.close("c:trendline"); +} + fn data_labels(w: &mut W, show: bool) { w.open0("c:dLbls"); for (n, v) in [ @@ -251,7 +369,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:catAx"); w.val("c:axId", AX_CAT); w.open0("c:scaling"); - w.val("c:orientation", "minMax"); + w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" }); w.close("c:scaling"); w.val("c:delete", 0); w.val("c:axPos", cat_pos); @@ -273,6 +391,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:valAx"); w.val("c:axId", AX_VAL); w.open0("c:scaling"); + if let Some(b) = c.y_log_base { + w.val("c:logBase", fmt_num(b)); + } w.val("c:orientation", "minMax"); if let Some(m) = c.y_max.filter(|v| v.is_finite()) { w.val("c:max", fmt_num(m)); @@ -286,10 +407,16 @@ fn axes(w: &mut W, c: &Chart, g: Group) { if c.gridlines { w.empty0("c:majorGridlines"); } + if c.minor_gridlines { + w.empty0("c:minorGridlines"); + } if let Some(t) = &c.axis_titles.1 { title(w, t); } - w.empty("c:numFmt", A::new().a("formatCode", if c.kind.percent() { "0%" } else { "General" }).a("sourceLinked", 1)); + match c.y_format.as_deref().filter(|f| !f.is_empty()) { + Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)), + None => w.empty("c:numFmt", A::new().a("formatCode", if c.kind.percent() { "0%" } else { "General" }).a("sourceLinked", 1)), + } w.val("c:majorTickMark", "none"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); @@ -320,6 +447,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Scatter => "c:scatterChart", Group::Radar => "c:radarChart", Group::Bubble => "c:bubbleChart", + Group::Stock => "c:stockChart", }; w.open0(tag); match g { @@ -332,7 +460,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Radar => w.val("c:radarStyle", if kind == ChartType::FilledRadar { "filled" } else { "marker" }), _ => {} } - w.val("c:varyColors", vary); + // `c:stockChart` has no `c:varyColors`. + if g != Group::Stock { + w.val("c:varyColors", vary); + } for (i, s) in list { series(w, c, s, *i, kind, g); } @@ -348,6 +479,24 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) } } Group::Line => w.val("c:marker", 1), + Group::Stock => { + w.open0("c:hiLowLines"); + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 9525)); + w.open0("a:solidFill"); + w.empty("a:schemeClr", A::new().a("val", "tx1")); + w.close("a:solidFill"); + w.close("a:ln"); + w.close("c:spPr"); + w.close("c:hiLowLines"); + if c.up_down_bars { + w.open0("c:upDownBars"); + w.val("c:gapWidth", 150); + w.empty0("c:upBars"); + w.empty0("c:downBars"); + w.close("c:upDownBars"); + } + } Group::Pie => w.val("c:firstSliceAng", 0), Group::Doughnut => { w.val("c:firstSliceAng", 0); @@ -362,10 +511,43 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) w.close(tag); } -/// The chart part XML. -pub fn chart_xml(c: &Chart) -> Vec { +/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart +/// is unchanged since it was read, else one written from the model. `None` for other chart types. +#[cfg(test)] +pub fn chartex_xml(c: &Chart) -> Option> { + chartex_part(c).map(|(x, _)| x) +} + +/// [`chartex_xml`], and whether it is the original part (so its kept relationships still apply). +pub fn chartex_part(c: &Chart) -> Option<(Vec, bool)> { + kept_chartex(c).map(|x| (x, true)).or_else(|| c.kind.is_chartex().then(|| (build_chartex(c), false))) +} + +fn kept_chartex(c: &Chart) -> Option> { + let raw = c.raw.as_ref()?; + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root.clone(); + crate::read::resolve_mc(&mut root, 0); + let mut back = crate::read::chartex_from(&root)?; + back.raw = c.raw.clone(); + // The sidecar is not modelled chart state. + back.kept = c.kept.clone(); + if &back != c { + return None; + } + let mut keep = doc.root; + // The workbook link stays only when its part is written back. + drop_unkept_refs(&mut keep, c); + let mut w = W::new(); + w.raw(&keep.to_xml()); + Some(w.finish()) +} + +/// The chart part XML, and whether it is the original part (so its relationships still apply). +pub fn chart_xml(c: &Chart) -> (Vec, bool) { // Reuse the kept part when the chart is unchanged since it was read. if let Some(raw) = &c.raw + && !raw.contains(NS_CX) && let Ok(doc) = crate::xml::parse(raw.as_bytes()) && doc.root.is("chartSpace") { @@ -374,12 +556,15 @@ pub fn chart_xml(c: &Chart) -> Vec { if let Some(back) = crate::read::chart_from_el(&root) { let mut me = c.clone(); me.raw = None; + // The sidecar is not modelled chart state. Comparing it would throw away an unchanged chart. + me.kept = Default::default(); if back == me { let mut keep = doc.root; - keep.remove_all(&["externalData", "userShapes"]); + // Without the parts those elements point at, PowerPoint repairs the file. + drop_unkept_refs(&mut keep, c); let mut w = W::new(); w.raw(&keep.to_xml()); - return w.finish(); + return (w.finish(), true); } } } @@ -447,6 +632,7 @@ pub fn chart_xml(c: &Chart) -> Vec { | ChartType::Bubble | ChartType::Radar | ChartType::FilledRadar + | ChartType::Stock ) { log::warn!("pptx: {} chart written as a column chart", c.kind.label()); } @@ -477,5 +663,324 @@ pub fn chart_xml(c: &Chart) -> Vec { sp_pr(&mut w, Some(f), None); } w.close("c:chartSpace"); + (w.finish(), false) +} + +/// The relationship ids of an unchanged chart that [`write_kept`] writes back: external ones, and +/// internal ones whose part was kept. +fn kept_rel_ids(c: &Chart) -> std::collections::HashSet<&str> { + let parts: std::collections::HashSet = c.kept.parts.iter().filter(|p| kept_payload(&p.name)).map(|p| crate::opc::norm(&p.name)).collect(); + c.kept.rels.iter().filter(|r| r.external || parts.contains(&crate::opc::norm(&r.part))).map(|r| r.id.as_str()).collect() +} + +/// A kept part that is written as a part of its own (rels parts are rewritten instead). +fn kept_payload(name: &str) -> bool { + !name.to_ascii_lowercase().ends_with(".rels") && crate::opc::sidecar_part(name) +} + +/// Drop `c:externalData` / `c:userShapes` (and the `cx:` ones) that point at a relationship that +/// is not written back: without the part PowerPoint repairs the file. +fn drop_unkept_refs(root: &mut crate::xml::El, c: &Chart) { + let ids = kept_rel_ids(c); + fn rec(e: &mut crate::xml::El, ids: &std::collections::HashSet<&str>, depth: usize) { + if depth > crate::xml::MAX_DEPTH { + return; + } + e.children.retain(|n| match n { + crate::xml::Node::El(x) if x.is("externalData") || x.is("userShapes") => x.attr("r:id").is_some_and(|id| ids.contains(id)), + _ => true, + }); + for ch in e.elements_mut() { + rec(ch, ids, depth + 1); + } + } + rec(root, &ids, 0); +} + +/// A fresh name for a kept part: `ppt//keptN.`. The writer generates no `kept…` +/// names and `n` is unique within the package, so a kept part never lands on (or duplicates) a +/// part the writer makes, whatever the source file called it. +fn kept_name(original: &str, n: u32) -> String { + let norm = crate::opc::norm(original); + let area = norm.split('/').nth(1).unwrap_or("embeddings"); + let area = if matches!(area, "embeddings" | "charts" | "drawings" | "media") { area } else { "embeddings" }; + let file = crate::opc::file_of(&norm); + let ext = file + .rsplit_once('.') + .map(|(_, e)| e) + .filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or("bin"); + format!("ppt/{area}/kept{n}.{ext}") +} + +/// Write an unchanged chart's own relationships and the parts they reach. Each kept part is +/// written under a fresh name ([`kept_name`]) and the relationships between kept parts are +/// rewritten to those names; a relationship to anything that was not kept is dropped. +/// `n_kept` numbers kept parts across the package. +pub fn write_kept(pkg: &mut crate::opc::PackageOut, chart_name: &str, c: &Chart, n_kept: &mut u32) { + use std::collections::HashMap; + + use crate::opc::{RelsOut, norm, relative, rels_path}; + + if c.kept.rels.is_empty() { + return; + } + // Original part name (normalized) → the name written. + let mut names: HashMap = HashMap::new(); + for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) { + let key = norm(&p.name); + if names.contains_key(&key) { + continue; + } + *n_kept += 1; + let new = kept_name(&p.name, *n_kept); + let ct = if !p.content_type.is_empty() { p.content_type.clone() } else { crate::mime_for(&p.name).to_string() }; + // An override per part: a kept part never sets the package-wide default for its extension. + pkg.xml_part(&new, &ct, p.bytes.clone()); + names.insert(key, new); + } + let mut rels = RelsOut::default(); + for r in &c.kept.rels { + if r.external { + rels.items.push((r.id.clone(), r.rel_type.clone(), r.target.clone(), true)); + } else if let Some(new) = names.get(&norm(&r.part)) { + rels.items.push((r.id.clone(), r.rel_type.clone(), relative(chart_name, new), false)); + } else { + log::warn!("pptx: chart relationship {} has no kept part; dropped", r.id); + } + } + pkg.rels(chart_name, &rels); + // The kept parts' own relationships, pointed at the new names. + for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) { + let Some(new_source) = names.get(&norm(&p.name)) else { continue }; + let rp = norm(&rels_path(&p.name)); + let Some(rels_part) = c.kept.parts.iter().find(|q| norm(&q.name) == rp) else { continue }; + let mut out = RelsOut::default(); + for r in crate::opc::rels_from_bytes(&rels_part.bytes, &p.name).items { + if r.external { + out.items.push((r.id, r.ty, r.target_raw, true)); + } else if let Some(new) = names.get(&norm(&r.target)) { + out.items.push((r.id, r.ty, relative(new_source, new), false)); + } else { + log::warn!("pptx: relationship {} of kept chart part {} has no kept target; dropped", r.id, p.name); + } + } + pkg.rels(new_source, &out); + } +} + +/// A `cx:tx` holding plain text. +fn cx_text(w: &mut W, t: &str) { + w.open0("cx:tx"); + w.open0("cx:txData"); + w.open0("cx:v"); + w.text(t); + w.close("cx:v"); + w.close("cx:txData"); + w.close("cx:tx"); +} + +/// A `cx:lvl` of points. +fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { + let mut a = A::new().a("ptCount", pts.len()); + if let Some(f) = format { + a = a.a("formatCode", f); + } + w.open("cx:lvl", a); + for (i, p) in pts.iter().enumerate() { + w.open("cx:pt", A::new().a("idx", i)); + w.text(p); + w.close("cx:pt"); + } + w.close("cx:lvl"); +} + +/// The `cx:layoutPr` of the region map series in a kept `chartEx` part. +fn kept_region_layout(raw: &str) -> Option { + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root; + crate::read::resolve_mc(&mut root, 0); + let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?; + let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?; + let lp = s.child("layoutPr")?; + // Written under our `cx` prefix; a part that bound chartex to another prefix is not copied. + lp.name.starts_with("cx:").then(|| lp.to_xml()) +} + +/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` +/// per series (categories and values), then the plot area with each series' layout, the axes +/// the layout uses, the title and the legend. +pub fn build_chartex(c: &Chart) -> Vec { + let mut w = W::new(); + w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX)); + w.open0("cx:chartData"); + for (i, s) in c.series.iter().enumerate() { + w.open("cx:data", A::new().a("id", i)); + if !c.categories.is_empty() { + w.open("cx:strDim", A::new().a("type", "cat")); + cx_level(&mut w, &c.categories, None); + for lv in &c.category_levels { + cx_level(&mut w, lv, None); + } + w.close("cx:strDim"); + } + let ty = match c.kind { + ChartType::Treemap | ChartType::Sunburst => "size", + ChartType::RegionMap => "colorVal", + _ => "val", + }; + w.open("cx:numDim", A::new().a("type", ty)); + let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); + cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); + w.close("cx:numDim"); + w.close("cx:data"); + } + w.close("cx:chartData"); + w.open0("cx:chart"); + if let Some(t) = &c.title { + w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0")); + cx_text(&mut w, t); + w.close("cx:title"); + } + w.open0("cx:plotArea"); + w.open0("cx:plotAreaRegion"); + let layout = match c.kind { + ChartType::Waterfall => "waterfall", + ChartType::Funnel => "funnel", + ChartType::Treemap => "treemap", + ChartType::Sunburst => "sunburst", + ChartType::BoxWhisker => "boxWhisker", + ChartType::RegionMap => "regionMap", + _ => "clusteredColumn", + }; + let one = !matches!(c.kind, ChartType::BoxWhisker); + for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { + w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}"))); + cx_text(&mut w, &s.name); + if s.data_labels.unwrap_or(c.data_labels) { + w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" })); + w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1")); + w.close("cx:dataLabels"); + } + w.empty("cx:dataId", A::new().a("val", i)); + match c.kind { + ChartType::Waterfall if !c.subtotals.is_empty() => { + w.open0("cx:layoutPr"); + w.open0("cx:subtotals"); + for t in &c.subtotals { + w.empty("cx:idx", A::new().a("val", t)); + } + w.close("cx:subtotals"); + w.close("cx:layoutPr"); + } + ChartType::Histogram | ChartType::Pareto => { + w.open0("cx:layoutPr"); + w.open("cx:binning", A::new().a("intervalClosed", "r")); + if let Some(size) = c.bin_size { + w.empty("cx:binSize", A::new().a("val", fmt_num(size))); + } else if let Some(n) = c.bin_count { + w.empty("cx:binCount", A::new().a("val", n)); + } + w.close("cx:binning"); + w.close("cx:layoutPr"); + } + ChartType::BoxWhisker => { + w.open0("cx:layoutPr"); + w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1")); + w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" })); + w.close("cx:layoutPr"); + } + ChartType::RegionMap => { + // The map's geography (projection, region level and the cached boundaries + // PowerPoint downloaded) is not modelled: carry it over from the part we read. + if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) { + w.raw(&lp); + } + } + _ => {} + } + w.close("cx:series"); + } + if c.kind == ChartType::Pareto { + w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}")); + w.empty("cx:axisId", A::new().a("val", "2")); + w.close("cx:series"); + } + w.close("cx:plotAreaRegion"); + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { + w.open("cx:axis", A::new().a("id", "0")); + w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" })); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + if c.kind != ChartType::Funnel { + w.open("cx:axis", A::new().a("id", "1")); + w.empty0("cx:valScaling"); + if c.gridlines { + w.empty0("cx:majorGridlines"); + } + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + if c.kind == ChartType::Pareto { + w.open("cx:axis", A::new().a("id", "2")); + w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0")); + w.empty0("cx:units"); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + } + w.close("cx:plotArea"); + if let Some(pos) = &c.legend { + w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0")); + } + w.close("cx:chart"); + w.close("cx:chartSpace"); w.finish() } + +#[cfg(test)] +mod region_map_tests { + use super::*; + + /// A region map part as PowerPoint writes it (our own numbers; the cached geography is a + /// stand-in blob). + const MAP: &str = r#"Sheet1!$A$2:$A$4MinskBrestGomelSheet1!$B$2:$B$41258Sheet1!$B$1SalesZGVja2NyYWZ0LXRlc3Q="#; + + #[test] + fn region_maps_keep_their_geography_through_save_and_edit() { + let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read"); + assert_eq!(c.kind, ChartType::RegionMap); + assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]); + assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)])); + // Unchanged: written back as read. + let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default(); + assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}"); + // Edited: rebuilt from the model, with the geography carried over. + let mut e = c.clone(); + if let Some(s) = e.series.first_mut() { + s.values[1] = Some(20.0); + } + let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default(); + assert!(built.contains(""#)); + assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed); + c.cat_reversed = false; + assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed); + } +} diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 16a069fe..371bad7e 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -95,6 +95,9 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { /// `a:blipFill` / `p:blipFill`. pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { + // An image that lives outside the package is a linked picture: `a:blip` names it through + // `r:link`; `r:embed` may only reference a part inside the package. + let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; @@ -105,8 +108,9 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil || adj.duotone.is_some() || adj.clear_color.is_some() || adj.bilevel.is_some() - || adj.alpha_inv; - let a = A::new().a("r:embed", &rid); + || adj.alpha_inv + || pf.artistic.is_some(); + let a = if linked { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) }; if has_kids { w.open("a:blip", a); if let Some(c) = adj.clear_color { @@ -140,6 +144,25 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil if adj.brightness != 0.0 || adj.contrast != 0.0 { w.empty("a:lum", A::new().a("bright", pct(adj.brightness.clamp(-1.0, 1.0))).a("contrast", pct(adj.contrast.clamp(-1.0, 1.0)))); } + // Artistic effect: the blip is the processed picture; the original rides in an + // `a14:imgLayer` with the effect's name, as PowerPoint writes it. + if let Some(art) = &pf.artistic + && art.effect.starts_with("artistic") + && art.effect.chars().all(|c| c.is_ascii_alphanumeric()) + && let Some(orig) = x.media_rel(o, art.original, "image") + { + w.open0("a:extLst"); + w.open("a:ext", A::new().a("uri", "{BEBA8EAE-BF5A-486C-A8C5-ECC9F3942E4B}")); + w.open("a14:imgProps", A::new().a("xmlns:a14", crate::opc::NS_A14)); + w.open("a14:imgLayer", A::new().a("r:embed", &orig)); + w.open0("a14:imgEffect"); + w.empty0(&format!("a14:{}", art.effect)); + w.close("a14:imgEffect"); + w.close("a14:imgLayer"); + w.close("a14:imgProps"); + w.close("a:ext"); + w.close("a:extLst"); + } w.close("a:blip"); } else { w.empty("a:blip", a); @@ -339,6 +362,15 @@ pub fn effects(w: &mut W, fx: &Effects) { w.close("a:effectLst"); } +/// `a:sp3d` for a bevel preset when the shape or theme style has no kept 3-D XML. +/// The preset name is limited to ASCII letters and digits so it cannot break the markup. +pub fn bevel_sp3d(preset: &str) -> Option { + if preset.is_empty() || preset.len() > 32 || !preset.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(format!("")) +} + pub fn xfrm(w: &mut W, tag: &str, x: &Xfrm, child: Option<&Xfrm>) { let x = if x.is_finite() { *x } else { Xfrm::default() }; let rot = ang(x.rot); @@ -377,9 +409,10 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink) Action::LastSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslide".into())), Action::EndShow => (String::new(), Some("ppaction://hlinkshowjump?jump=endshow".into())), Action::LastViewed => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslideviewed".into())), - Action::CustomShow { name } => { + Action::CustomShow { name, returns } => { let id = x.p.custom_shows.iter().position(|c| c.name == *name).unwrap_or(0); - (String::new(), Some(format!("ppaction://customshow?id={id}&return=true"))) + let ret = if *returns { "true" } else { "false" }; + (String::new(), Some(format!("ppaction://customshow?id={id}&return={ret}"))) } Action::Program { path } => { if path.is_empty() { @@ -389,14 +422,28 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink) } Action::PlayMedia => (String::new(), Some("ppaction://media".into())), }; - w.empty( - tag, - A::new() - .a("r:id", rid) - .o("action", action) - .o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str())) - .t("highlightClick", h.highlight_click), - ); + let attrs = A::new() + .a("r:id", rid) + .o("action", action) + .o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str())) + .t("highlightClick", h.highlight_click) + .t("endSnd", h.stop_sound); + let sound = h.sound.and_then(|id| x.media_rel(o, id, "audio").map(|rid| (rid, id))); + let Some((sound_rid, sound_id)) = sound else { + w.empty(tag, attrs); + return; + }; + w.open(tag, attrs); + let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into()); + w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name)); + w.close(tag); +} + +/// Display name PowerPoint stores on `a:snd`. +fn sound_label(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + let label: String = file.chars().filter(|c| c.is_ascii() && !c.is_control() && *c != '"' && *c != '<' && *c != '>').take(80).collect(); + if label.is_empty() { "sound.wav".into() } else { label } } pub fn body_pr(w: &mut W, b: &BodyProps) { @@ -413,7 +460,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .b("rtlCol", b.rtl_col) .o("anchor", b.anchor.map(|a| a.xml())) .b("anchorCtr", b.anchor_ctr) - .b("upright", b.upright); + .b("upright", b.upright) + .b("spcFirstLastPara", b.space_first_last); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); @@ -521,7 +569,12 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") { Some(rid) => { w.open0("a:buBlip"); - w.empty("a:blip", A::new().a("r:embed", rid)); + let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { + A::new().a("r:link", &rid) + } else { + A::new().a("r:embed", &rid) + }; + w.empty("a:blip", a); w.close("a:buBlip"); } None => w.empty("a:buChar", A::new().a("char", "•")), @@ -579,17 +632,18 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { .o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64)) .o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0)))) .b("noProof", r.no_proof); + let has_fx = r.shadow.is_some() || r.inner_shadow.is_some() || r.glow.is_some() || r.reflection.is_some() || r.soft_edge.is_some(); let has_kids = r.outline.is_some() || r.fill.is_some() - || r.shadow.is_some() - || r.glow.is_some() + || has_fx || r.highlight.is_some() || r.underline_color.is_some() || r.font.is_some() || r.font_ea.is_some() || r.font_cs.is_some() || r.font_sym.is_some() - || r.link.is_some(); + || r.link.is_some() + || r.hover.is_some(); if !has_kids { w.empty(tag, a); return; @@ -604,8 +658,19 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { f => fill(w, x, o, f), } } - if r.shadow.is_some() || r.glow.is_some() { - effects(w, &Effects { outer_shadow: r.shadow.clone(), glow: r.glow.clone(), ..Default::default() }); + if has_fx { + // Same child order as a shape's effect list: glow, inner shadow, outer shadow, reflection, soft edge. + effects( + w, + &Effects { + outer_shadow: r.shadow.clone(), + inner_shadow: r.inner_shadow.clone(), + glow: r.glow.clone(), + reflection: r.reflection.clone(), + soft_edge: r.soft_edge, + ..Default::default() + }, + ); } if let Some(h) = &r.highlight { w.open0("a:highlight"); @@ -624,6 +689,10 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { if let Some(h) = &r.link { hyperlink(w, x, o, "a:hlinkClick", h); } + // Schema order: hlinkClick, hlinkMouseOver. + if let Some(h) = &r.hover { + hyperlink(w, x, o, "a:hlinkMouseOver", h); + } w.close(tag); } @@ -677,8 +746,9 @@ fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) { pub fn paragraph(w: &mut W, x: &mut Exp, o: &mut Out, p: &Paragraph, math: bool, index: usize) { w.open0("a:p"); - if p.level > 0 || !is_default_ppr(&p.props) { - ppr(w, x, o, "a:pPr", Some(p.level), &p.props, None); + let def = (p.def_props != RunProps::default()).then_some(&p.def_props); + if p.level > 0 || !is_default_ppr(&p.props) || def.is_some() { + ppr(w, x, o, "a:pPr", Some(p.level), &p.props, def); } for (ri, r) in p.runs.iter().enumerate() { match &r.kind { diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..bea8dc64 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -1,6 +1,6 @@ //! PPTX export: [`Presentation`] → package. -mod chart; +pub(crate) mod chart; pub(crate) mod dml; mod shapes; mod theme; @@ -10,28 +10,41 @@ use std::collections::HashMap; use std::sync::Arc; use deckcraft_geom::Xfrm; -use deckcraft_model::text::{Paragraph, TextBody}; +use deckcraft_model::text::{ListStyle, Paragraph, TextBody}; +use deckcraft_model::theme::Theme; use deckcraft_model::{ Background, Comment, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId, defaults, }; use crate::PptxError; -use crate::opc::{self, NS_A, NS_P, NS_P14, NS_R, PackageOut, RelsOut}; +use crate::opc::{self, NS_A, NS_P, NS_P14, NS_P188, NS_R, PackageOut, RelsOut}; use crate::xml::{A, W}; -const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; +pub(crate) const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; +pub(crate) const CT_TEMPLATE: &str = "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"; +pub(crate) const CT_SHOW: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml"; const CT_SLIDE: &str = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"; const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"; const CT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"; const CT_NOTES_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"; +const CT_HANDOUT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml"; const CT_NOTES: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"; const CT_THEME: &str = "application/vnd.openxmlformats-officedocument.theme+xml"; const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"; const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml"; const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"; +const CT_TAGS: &str = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml"; const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; +const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx"; const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml"; const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml"; +/// Modern threaded comments (read by PowerPoint 2021+ and DeckCraft; the flattened legacy +/// part next to it serves older readers). +pub(crate) const CT_COMMENTS_MODERN: &str = "application/vnd.ms-powerpoint.comments+xml"; +const CT_AUTHORS_MODERN: &str = "application/vnd.ms-powerpoint.authors+xml"; +const RT_COMMENTS_MODERN: &str = "http://schemas.microsoft.com/office/2018/10/relationships/comments"; +const RT_AUTHORS_MODERN: &str = "http://schemas.microsoft.com/office/2018/10/relationships/authors"; const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml"; const CT_APP: &str = "application/vnd.openxmlformats-officedocument.extended-properties+xml"; @@ -43,6 +56,11 @@ pub struct Exp<'a> { n_image: u32, n_media: u32, n_chart: u32, + n_diagram: u32, + n_tags: u32, + n_chartex: u32, + /// Parts kept from an unchanged chart (`ppt/…/keptN.ext`). + n_kept: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -82,6 +100,7 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String { "audio/x-ms-wma" => "wma", "audio/ogg" => "ogg", "audio/flac" => "flac", + "model/gltf-binary" => "glb", _ => "", }; if !by_ct.is_empty() { @@ -158,14 +177,63 @@ impl Exp<'_> { }; o.rels.add("image", &opc::relative(&o.name, &part), false) } + /// The number of the next SmartArt graphic's parts. + pub fn next_diagram(&mut self) -> u32 { + self.n_diagram += 1; + self.n_diagram + } + /// A new `ppt/tags/tagN.xml` part holding `tags`, related from `o`. + pub fn tags_rel(&mut self, o: &mut Out, tags: &Tags) -> String { + self.n_tags += 1; + let name = format!("ppt/tags/tag{}.xml", self.n_tags); + let mut w = W::new(); + root_open(&mut w, "p:tagLst", A::new()); + for (n, v) in tags.iter().take(10_000) { + w.empty("p:tag", A::new().a("name", n).a("val", v)); + } + w.close("p:tagLst"); + self.pkg.xml_part(&name, CT_TAGS, w.finish()); + o.rels.add_new("tags", &opc::relative(&o.name, &name), false) + } + /// An unchanged Office 2016 chart's `chartEx` part and its relationship id. + pub fn chartex_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> Option { + let (xml, preserved) = chart::chartex_part(c)?; + self.n_chartex += 1; + let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex); + self.pkg.xml_part(&name, CT_CHARTEX, xml); + if preserved { + chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept); + } else if !c.kept.parts.is_empty() { + log::warn!("pptx: chart data changed; the embedded workbook was not written back"); + } + Some(o.rels.add_new(RT_CHARTEX, &opc::relative(&o.name, &name), false)) + } + pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); - self.pkg.xml_part(&name, CT_CHART, chart::chart_xml(c)); + let (xml, preserved) = chart::chart_xml(c); + self.pkg.xml_part(&name, CT_CHART, xml); + if preserved { + chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept); + } else if !c.kept.parts.is_empty() { + log::warn!("pptx: chart data changed; the embedded workbook was not written back"); + } o.rels.add_new("chart", &opc::relative(&o.name, &name), false) } } +/// `p:custDataLst` pointing at a tags part, when there are tags. +pub fn cust_data(w: &mut W, x: &mut Exp, o: &mut Out, tags: &Tags) { + if tags.is_empty() { + return; + } + let id = x.tags_rel(o, tags); + w.open0("p:custDataLst"); + w.empty("p:tags", A::new().a("r:id", id)); + w.close("p:custDataLst"); +} + fn root_open(w: &mut W, tag: &str, extra: A) { let mut a = A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:p", NS_P); a.v.extend(extra.v); @@ -194,7 +262,9 @@ fn background(w: &mut W, x: &mut Exp, o: &mut Out, bg: &Background) { w.close("p:bg"); } -fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape]) -> shapes::IdMap { +type Tags = [(String, String)]; + +fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape], tags: &Tags) -> shapes::IdMap { w.open("p:cSld", A::new().o("name", (!name.is_empty()).then_some(name))); if let Some(bg) = bg { background(w, x, o, bg); @@ -207,6 +277,7 @@ fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background> shapes::shapes(w, x, o, &mut cx, list, 0); } w.close("p:spTree"); + cust_data(w, x, o, tags); w.close("p:cSld"); ids } @@ -281,6 +352,31 @@ fn ext_children(raw: Option<&str>) -> Option { Some(raw.to_string()) } +/// The slide's extension list, plus the modern-comments anchor when the slide has a threaded +/// comment part: the rel reference is how PowerPoint finds it. +fn slide_ext_list(w: &mut W, raw: Option<&str>, comment_rel: Option<&str>) { + let mut exts: Vec = raw + .filter(|r| !r.trim().is_empty()) + .and_then(|raw| crate::xml::parse(format!("{raw}").as_bytes()).ok()) + .map(|d| d.root.elements().filter(|e| e.is("ext") && e.has_attr("uri")).map(|e| e.to_xml()).collect()) + .unwrap_or_default(); + if let Some(rid) = comment_rel { + let mut e = W::frag(); + e.open("p:ext", A::new().a("uri", crate::COMMENTS_EXT_URI)); + e.empty("p188:commentRel", A::new().a("xmlns:p188", NS_P188).a("r:id", rid)); + e.close("p:ext"); + exts.push(e.s); + } + if exts.is_empty() { + return; + } + w.open0("p:extLst"); + for e in exts { + w.raw(&e); + } + w.close("p:extLst"); +} + fn notes_master_shapes(size: deckcraft_geom::Size) -> Vec { let (w, h) = (size.width, size.height); let mk = |id: u32, name: &str, kind: PhType, idx: u32, x: f64, y: f64, ww: f64, hh: f64| Shape { @@ -308,7 +404,46 @@ fn notes_master_shapes(size: deckcraft_geom::Size) -> Vec { ] } -fn notes_style() -> deckcraft_model::text::ListStyle { +/// What the notes master should be on save. A master we read is written back, including its +/// header, footer, date and slide-number text. A deck that has never had one gets the placeholders +/// we generate. +struct NotesMasterWrite { + shapes: Vec, + background: Option, + color_map: Vec<(String, String)>, + style: ListStyle, + theme: Option, +} + +fn notes_master_write(p: &Presentation) -> Option { + let nm = p.notes_master.as_deref(); + let kept = nm.filter(|m| !m.shapes.is_empty()); + let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); + if !has_notes && nm.is_none() { + return None; + } + let default_bg = || Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }; + Some(match kept { + Some(m) => NotesMasterWrite { + shapes: m.shapes.clone(), + background: Some(m.background.clone().unwrap_or_else(default_bg)), + color_map: m.color_map.clone(), + style: if m.body_style.is_empty() { notes_style() } else { m.body_style.clone() }, + theme: Some(m.theme.clone()), + }, + // A master without placeholders of its own gets PowerPoint's defaults, keeping its + // background and colour map. + None => NotesMasterWrite { + shapes: notes_master_shapes(p.notes_size), + background: Some(nm.and_then(|m| m.background.clone()).unwrap_or_else(default_bg)), + color_map: nm.map(|m| m.color_map.clone()).unwrap_or_default(), + style: notes_style(), + theme: None, + }, + }) +} + +fn notes_style() -> ListStyle { let mut ls = deckcraft_model::text::ListStyle::default(); for i in 0..9u8 { ls.set( @@ -331,12 +466,24 @@ fn notes_style() -> deckcraft_model::text::ListStyle { ls } +/// A plausible xsd:dateTime (`YYYY-MM-DD[THH:MM:SS(.frac)?(Z|±HH:MM)?]`): the ranges are +/// checked, so a model value like `2025-13-45T99:99` falls back to the default instead of +/// reaching a required `dt`/`created` attribute. fn valid_date(s: &str) -> bool { let b = s.as_bytes(); - b.len() >= 10 - && b.iter().take(4).all(|c| c.is_ascii_digit()) - && b.get(4) == Some(&b'-') - && s.chars().all(|c| c.is_ascii_alphanumeric() || "-:.+TZ".contains(c)) + let n = |r: std::ops::Range| s.get(r).and_then(|v| v.parse::().ok()); + let (Some(_), Some(mo), Some(d)) = (n(0..4), n(5..7), n(8..10)) else { return false }; + if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || !(1..=31).contains(&d) { + return false; + } + match b.get(10) { + None => true, + Some(b'T') => match (n(11..13), n(14..16), n(17..19)) { + (Some(h), Some(mi), Some(sec)) => h <= 23 && mi <= 59 && sec <= 60 && b.get(13) == Some(&b':') && b.get(16) == Some(&b':'), + _ => false, + }, + Some(_) => false, + } } /// A copy of the presentation with every slide pointing at a layout that exists. @@ -354,8 +501,11 @@ fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> { } for m in q.masters.iter_mut() { if m.layouts.is_empty() { - let id = LayoutId(q.next_id.saturating_add(1)); - q.next_id = id.0; + // An exhausted id space exports without the synthesized layout rather than with a + // duplicated one (#407). + let Some(raw) = q.next_id.max(1).checked_add(1) else { continue }; + let id = LayoutId(raw); + q.next_id = raw; Arc::make_mut(m).layouts.push(Layout { id, name: "Blank".into(), @@ -375,7 +525,7 @@ fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> { std::borrow::Cow::Owned(q) } -pub fn export(p: &Presentation) -> Result, PptxError> { +pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { let p = sanitized(p); let p: &Presentation = &p; let mut x = Exp { @@ -385,6 +535,10 @@ pub fn export(p: &Presentation) -> Result, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_diagram: 0, + n_tags: 0, + n_chartex: 0, + n_kept: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, @@ -434,31 +588,53 @@ pub fn export(p: &Presentation) -> Result, PptxError> { master_rids.push((master_id, pres.rels.add("slideMaster", &opc::relative("ppt/presentation.xml", &mname), false))); } - // Notes master when any slide has notes. - let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); + // Notes master when a slide has notes, or the file already has one (its header and footer + // live on the master, not on the notes slides). let notes_master = "ppt/notesMasters/notesMaster1.xml"; let mut notes_master_rid = None; - if has_notes { + if let Some(nm) = notes_master_write(p) { let tname = format!("ppt/theme/theme{}.xml", p.masters.len() + 1); let mut no = Out::new(notes_master); no.rels.add("theme", &opc::relative(notes_master, &tname), false); - let theme = p.notes_master.as_ref().map(|m| m.theme.clone()).or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default(); + let theme = nm.theme.or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default(); let mut to = Out::new(&tname); let txml = theme::theme_xml(&mut x, &mut to, &theme); x.pkg.xml_part(&tname, CT_THEME, txml); x.pkg.rels(&tname, &to.rels); let mut w = W::new(); root_open(&mut w, "p:notesMaster", A::new()); - let bg = Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }; - csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size)); - clr_map(&mut w, &[]); - dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", ¬es_style()); + csld(&mut w, &mut x, &mut no, "", nm.background.as_ref(), &nm.shapes, &[]); + clr_map(&mut w, &nm.color_map); + dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", &nm.style); w.close("p:notesMaster"); x.pkg.xml_part(notes_master, CT_NOTES_MASTER, w.finish()); x.pkg.rels(notes_master, &no.rels); notes_master_rid = Some(pres.rels.add("notesMaster", &opc::relative("ppt/presentation.xml", notes_master), false)); } + // Handout master. Its header, footer, date and slide-number text live on this part; a deck + // that has never had one does not gain an empty master on save. + let handout_path = "ppt/handoutMasters/handoutMaster1.xml"; + let mut handout_rid = None; + if let Some(hm) = p.handout_master.as_ref() { + let tindex = p.masters.len() + 1 + usize::from(notes_master_rid.is_some()); + let tname = format!("ppt/theme/theme{tindex}.xml"); + let mut ho = Out::new(handout_path); + ho.rels.add("theme", &opc::relative(handout_path, &tname), false); + let mut to = Out::new(&tname); + let txml = theme::theme_xml(&mut x, &mut to, &hm.theme); + x.pkg.xml_part(&tname, CT_THEME, txml); + x.pkg.rels(&tname, &to.rels); + let mut w = W::new(); + root_open(&mut w, "p:handoutMaster", A::new()); + csld(&mut w, &mut x, &mut ho, &hm.name, hm.background.as_ref(), &hm.shapes, &[]); + clr_map(&mut w, &hm.color_map); + w.close("p:handoutMaster"); + x.pkg.xml_part(handout_path, CT_HANDOUT_MASTER, w.finish()); + x.pkg.rels(handout_path, &ho.rels); + handout_rid = Some(pres.rels.add("handoutMaster", &opc::relative("ppt/presentation.xml", handout_path), false)); + } + // Comment authors. let mut authors: Vec<(String, String, u32)> = vec![]; let mut n_comments = 0u32; @@ -481,13 +657,21 @@ pub fn export(p: &Presentation) -> Result, PptxError> { x.pkg.xml_part(&nname, CT_NOTES, xml); x.pkg.rels(&nname, &no.rels); } + let mut comment_rel = None; if !s.comments.is_empty() { n_comments += 1; let cname = format!("ppt/comments/comment{n_comments}.xml"); so.rels.add("comments", &opc::relative(&sname, &cname), false); x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors)); + // The modern threaded part next to the flattened legacy one (issue #70): + // PowerPoint 2021+ and DeckCraft read replies and resolved state from it, + // older readers keep the legacy part. + let mname = format!("ppt/comments/modernComment{n_comments}.xml"); + let rid = so.rels.add(RT_COMMENTS_MODERN, &opc::relative(&sname, &mname), false); + x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors)); + comment_rel = Some(rid); } - let xml = slide_xml(&mut x, &mut so, s); + let xml = slide_xml(&mut x, &mut so, s, comment_rel.as_deref()); x.pkg.xml_part(&sname, CT_SLIDE, xml); x.pkg.rels(&sname, &so.rels); slide_rids.push(pres.rels.add("slide", &opc::relative("ppt/presentation.xml", &sname), false)); @@ -502,6 +686,15 @@ pub fn export(p: &Presentation) -> Result, PptxError> { w.close("p:cmAuthorLst"); x.pkg.xml_part(name, CT_AUTHORS, w.finish()); pres.rels.add("commentAuthors", "commentAuthors.xml", false); + // The modern authors part the threaded comments' authorId GUIDs point at. + let mut w = W::new(); + w.open("p188:authorLst", A::new().a("xmlns:p188", NS_P188)); + for (n, ini, _) in authors.iter() { + w.empty("p188:author", A::new().a("id", author_guid(n)).a("name", n).a("initials", ini).a("userId", "").a("providerId", "None")); + } + w.close("p188:authorLst"); + x.pkg.xml_part("ppt/authors.xml", CT_AUTHORS_MODERN, w.finish()); + pres.rels.add(RT_AUTHORS_MODERN, "authors.xml", false); } // Presentation-level parts. @@ -511,12 +704,37 @@ pub fn export(p: &Presentation) -> Result, PptxError> { pres.rels.add("viewProps", "viewProps.xml", false); pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); - x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); + x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p)); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); - let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); - x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml); + let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), handout_rid.as_deref(), &slide_rids); + x.pkg.xml_part("ppt/presentation.xml", main_ct, pxml); + // Kept parts never replace a part the writer makes. + let kept: Vec<_> = p + .kept_parts + .iter() + .filter(|k| { + let n = k.name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && !n.starts_with("docprops/core") + && !n.starts_with("docprops/app") + && !n.starts_with("[content_types]") + && n != "_rels/.rels" + }) + .collect(); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && !k.from_root) { + pres.rels.add_new(&k.rel_type, &opc::relative("ppt/presentation.xml", &k.name), false); + } x.pkg.rels("ppt/presentation.xml", &pres.rels); + for k in &kept { + if k.content_type.is_empty() { + x.pkg.raw(&k.name, k.xml.clone().into_bytes()); + } else { + x.pkg.xml_part(&k.name, &k.content_type, k.xml.clone().into_bytes()); + } + } // Document properties and package relationships. x.pkg.xml_part("docProps/core.xml", CT_CORE, core_xml(p)); @@ -525,6 +743,9 @@ pub fn export(p: &Presentation) -> Result, PptxError> { root.add("officeDocument", "ppt/presentation.xml", false); root.add(opc::RT_CORE, "docProps/core.xml", false); root.add("extended-properties", "docProps/app.xml", false); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && k.from_root) { + root.add_new(&k.rel_type, &k.name, false); + } x.pkg.rels("", &root); x.pkg.finish() } @@ -536,7 +757,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { "p:sldLayout", A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve), ); - csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes); + csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes, &[]); clr_map_ovr(&mut w, l.color_map.as_deref()); ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref()); w.close("p:sldLayout"); @@ -546,7 +767,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sldMaster", A::new().t("preserve", m.preserve)); - csld(&mut w, x, o, &m.name, m.background.as_ref(), &m.shapes); + csld(&mut w, x, o, &m.name, m.background.as_ref(), &m.shapes, &[]); clr_map(&mut w, &m.color_map); w.open0("p:sldLayoutIdLst"); for (id, rid) in layouts { @@ -565,10 +786,10 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) - w.finish() } -fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { +fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide, comment_rel: Option<&str>) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); - let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes); + let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes, &s.tags); clr_map_ovr(&mut w, s.color_map.as_deref()); if let Some(t) = &s.transition { timing::transition(&mut w, x, o, t); @@ -576,7 +797,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { if !s.animations.is_empty() { timing::timing(&mut w, x, &s.animations, &s.shapes, &ids); } - ext_list(&mut w, s.raw_ext.as_deref()); + slide_ext_list(&mut w, s.raw_ext.as_deref(), comment_rel); w.close("p:sld"); w.finish() } @@ -599,7 +820,7 @@ fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec { text: Some(tb), ..Default::default() }; - csld(&mut w, x, o, "", None, &[img, body]); + csld(&mut w, x, o, "", None, &[img, body], &[]); w.open0("p:clrMapOvr"); w.empty0("a:masterClrMapping"); w.close("p:clrMapOvr"); @@ -618,15 +839,7 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V } } for c in flat { - let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() }; - let ai = match authors.iter().position(|(n, _, _)| *n == author) { - Some(i) => i, - None => { - let ini = if c.initials.is_empty() { author.chars().filter(|c| c.is_alphabetic()).take(2).collect() } else { c.initials.clone() }; - authors.push((author.clone(), ini, 0)); - authors.len() - 1 - } - }; + let ai = author_slot(c, authors); let idx = match authors.get_mut(ai) { Some(a) => { a.2 += 1; @@ -645,6 +858,87 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V w.finish() } +/// The author's slot in the shared bucket list, adding them on first use. Legacy parts index the +/// list by number, the modern authors part gives every slot a GUID. +fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>) -> usize { + let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() }; + match authors.iter().position(|(n, _, _)| *n == author) { + Some(i) => i, + None => { + let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() }; + authors.push((author.clone(), ini, 0)); + authors.len() - 1 + } + } +} + +/// The modern (p188) threaded-comment part: replies nest and the resolved state is kept, which +/// the flattened legacy part cannot express (issue #70). +fn modern_comments_xml(list: &[Comment], part: &str, authors: &mut Vec<(String, String, u32)>) -> Vec { + let mut w = W::new(); + w.open("p188:cmLst", A::new().a("xmlns:p188", NS_P188)); + for (ti, c) in list.iter().take(10_000).enumerate() { + modern_comment(&mut w, c, part, ti, authors, 0); + } + w.close("p188:cmLst"); + w.finish() +} + +fn modern_comment(w: &mut W, c: &Comment, part: &str, ti: usize, authors: &mut Vec<(String, String, u32)>, depth: usize) { + let ai = author_slot(c, authors); + let author = authors.get(ai).map(|(n, _, _)| n.clone()).unwrap_or_default(); + let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() }; + let mut a = A::new().a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}"))).a("authorId", author_guid(&author)).a("created", dt); + if c.resolved { + a = a.a("status", "resolved"); + } + w.open("p188:cm", a); + w.empty0("p188:unknownAnchor"); + let emu = |v: f64| if v.is_finite() { (v * 12_700.0).round().clamp(0.0, 1.0e9) as i64 } else { 0 }; + w.empty("p188:pos", A::new().a("x", emu(c.x)).a("y", emu(c.y))); + if depth < 4 && !c.replies.is_empty() { + w.open0("p188:replyLst"); + for (ri, r) in c.replies.iter().take(1000).enumerate() { + let ri_ai = author_slot(r, authors); + let r_author = authors.get(ri_ai).map(|(n, _, _)| n.clone()).unwrap_or_default(); + let rdt = if valid_date(&r.date) { r.date.clone() } else { "2024-01-01T00:00:00.000".into() }; + let mut ra = A::new() + .a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}:{ri}"))) + .a("authorId", author_guid(&r_author)) + .a("created", rdt); + if r.resolved { + ra = ra.a("status", "resolved"); + } + w.open("p188:reply", ra); + tx_body(w, &r.text); + w.close("p188:reply"); + } + w.close("p188:replyLst"); + } + tx_body(w, &c.text); + w.close("p188:cm"); +} + +/// An `a:txBody` with one paragraph per line, the shape the reader re-joins with `\n`. +fn tx_body(w: &mut W, text: &str) { + w.open0("p188:txBody"); + w.empty0("a:bodyPr"); + for line in text.split('\n') { + w.open0("a:p"); + w.open0("a:r"); + w.empty("a:rPr", A::new().a("lang", "en-US")); + w.elt("a:t", line); + w.close("a:r"); + w.close("a:p"); + } + w.close("p188:txBody"); +} + +/// The modern author id for a name: the same GUID the authors part writes. +fn author_guid(name: &str) -> String { + crate::tables::guid(&format!("deckcraft-comment-author:{name}")) +} + /// Sections with every slide in exactly one section, in slide order. fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { let mut out: Vec<(String, Vec)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect(); @@ -665,11 +959,99 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { out } -fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, slides: &[String]) -> Vec { +/// One embedded typeface, with the relationship id of each face that had bytes. +struct WrittenFont { + typeface: String, + panose: Option, + pitch_family: Option, + charset: Option, + regular: Option, + bold: Option, + italic: Option, + bold_italic: Option, +} + +impl WrittenFont { + fn any(&self) -> bool { + self.regular.is_some() || self.bold.is_some() || self.italic.is_some() || self.bold_italic.is_some() + } +} + +/// Font parts for `p:embeddedFontLst`. One part per distinct media item, named `ppt/fonts/fontN.fntdata`. +fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec { + struct Plan { + typeface: String, + panose: Option, + pitch_family: Option, + charset: Option, + regular: Option, + bold: Option, + italic: Option, + bold_italic: Option, + } + let mut plans = Vec::new(); + for font in x.p.embedded_fonts.iter().take(64) { + let typeface = font.typeface.trim(); + if typeface.is_empty() { + continue; + } + plans.push(Plan { + typeface: typeface.chars().take(255).collect(), + panose: font.panose.clone().filter(|s| s.len() == 20 && s.chars().all(|c| c.is_ascii_hexdigit())), + pitch_family: font.pitch_family, + charset: font.charset, + regular: font.regular, + bold: font.bold, + italic: font.italic, + bold_italic: font.bold_italic, + }); + } + let mut seen_part: HashMap = HashMap::new(); + let mut n = 0u32; + let mut rel = |x: &mut Exp, o: &mut Out, id: Option| -> Option { + let id = id?; + if let Some(rid) = seen_part.get(&id) { + return Some(rid.clone()); + } + let m = x.p.media(id)?; + if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 { + return None; + } + let bytes = m.data.as_ref().clone(); + n = n.saturating_add(1); + let name = format!("ppt/fonts/font{n}.fntdata"); + x.pkg.binary(&name, "application/x-fontdata", bytes); + let rid = o.rels.add("font", &opc::relative("ppt/presentation.xml", &name), false); + seen_part.insert(id, rid.clone()); + Some(rid) + }; + let mut seen_face = Vec::new(); + let mut out = Vec::new(); + for font in plans { + let written = WrittenFont { + typeface: font.typeface, + panose: font.panose, + pitch_family: font.pitch_family, + charset: font.charset, + regular: rel(x, o, font.regular), + bold: rel(x, o, font.bold), + italic: rel(x, o, font.italic), + bold_italic: rel(x, o, font.bold_italic), + }; + if written.any() && !seen_face.iter().any(|s| s == &written.typeface) { + seen_face.push(written.typeface.clone()); + out.push(written); + } + } + out +} + +fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, handout: Option<&str>, slides: &[String]) -> Vec { + let fonts = write_embedded_fonts(x, o); let p = x.p; let mut w = W::new(); let first = (p.first_slide_number != 1).then_some(p.first_slide_number.min(9999)); - root_open(&mut w, "p:presentation", A::new().a("saveSubsetFonts", 1).o("firstSlideNum", first)); + root_open(&mut w, "p:presentation", A::new().a("saveSubsetFonts", 1).t("embedTrueTypeFonts", !fonts.is_empty()).o("firstSlideNum", first)); w.open0("p:sldMasterIdLst"); for (id, rid) in masters { w.empty("p:sldMasterId", A::new().a("id", id).a("r:id", rid)); @@ -680,6 +1062,11 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: w.empty("p:notesMasterId", A::new().a("r:id", n)); w.close("p:notesMasterIdLst"); } + if let Some(h) = handout { + w.open0("p:handoutMasterIdLst"); + w.empty("p:handoutMasterId", A::new().a("r:id", h)); + w.close("p:handoutMasterIdLst"); + } if !slides.is_empty() { w.open0("p:sldIdLst"); for (i, rid) in slides.iter().enumerate() { @@ -690,6 +1077,23 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: let clamp = |v: f64| dml::emu(v).clamp(914_400, 51_206_400); w.empty("p:sldSz", A::new().a("cx", clamp(p.slide_size.width)).a("cy", clamp(p.slide_size.height))); w.empty("p:notesSz", A::new().a("cx", dml::emu_pos(p.notes_size.width).max(1)).a("cy", dml::emu_pos(p.notes_size.height).max(1))); + if !fonts.is_empty() { + w.open0("p:embeddedFontLst"); + for f in &fonts { + w.open0("p:embeddedFont"); + w.empty( + "p:font", + A::new().a("typeface", &f.typeface).o("panose", f.panose.clone()).o("pitchFamily", f.pitch_family).o("charset", f.charset), + ); + for (tag, rid) in [("p:regular", &f.regular), ("p:bold", &f.bold), ("p:italic", &f.italic), ("p:boldItalic", &f.bold_italic)] { + if let Some(rid) = rid { + w.empty(tag, A::new().a("r:id", rid)); + } + } + w.close("p:embeddedFont"); + } + w.close("p:embeddedFontLst"); + } if !p.custom_shows.is_empty() { w.open0("p:custShowLst"); for (i, cs) in p.custom_shows.iter().enumerate() { @@ -706,6 +1110,7 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: } w.close("p:custShowLst"); } + cust_data(&mut w, x, o, &p.tags); let dts = if p.default_text_style.is_empty() { defaults::default_text_style() } else { p.default_text_style.clone() }; dml::list_style(&mut w, x, o, "p:defaultTextStyle", &dts); let sections = section_slides(p); @@ -767,13 +1172,34 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.finish() } -fn view_props_xml() -> Vec { +fn view_props_xml(p: &Presentation) -> Vec { let mut w = W::new(); root_open(&mut w, "p:viewPr", A::new()); w.open0("p:normalViewPr"); w.empty("p:restoredLeft", A::new().a("sz", 15620)); w.empty("p:restoredTop", A::new().a("sz", 94660)); w.close("p:normalViewPr"); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open0("p:slideViewPr"); + w.open0("p:cSldViewPr"); + w.open0("p:cViewPr"); + w.open0("p:scale"); + w.empty("a:sx", A::new().a("n", 100).a("d", 100)); + w.empty("a:sy", A::new().a("n", 100).a("d", 100)); + w.close("p:scale"); + w.empty("p:origin", A::new().a("x", 0).a("y", 0)); + w.close("p:cViewPr"); + w.open0("p:guideLst"); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.empty("p:guide", a); + } + w.close("p:guideLst"); + w.close("p:cSldViewPr"); + w.close("p:slideViewPr"); + } w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200)); w.close("p:viewPr"); w.finish() diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..3c3f95eb 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -1,4 +1,4 @@ -//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink and kept objects. +//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models and kept objects. use std::collections::{HashMap, HashSet}; @@ -100,7 +100,10 @@ pub fn geometry(w: &mut W, g: &Geom) { vals.push(defaults.get(vals.len()).copied().unwrap_or(0.0)); } w.open0("a:avLst"); - let single = if defaults.is_empty() { vals.len() == 1 } else { defaults.len() == 1 }; + // A lone guide is `adj`, except in these presets, which name it `adj1` (as PowerPoint + // writes them); PowerPoint won't open a file that renames it (#214). + let adj1 = matches!(name, "bentConnector3" | "curvedConnector3" | "frame" | "mathPlus" | "mathMinus" | "mathMultiply"); + let single = !adj1 && if defaults.is_empty() { vals.len() == 1 } else { defaults.len() == 1 }; for (i, v) in vals.iter().enumerate() { let n = if single && i == 0 { "adj".to_string() } else { format!("adj{}", i + 1) }; let v = if v.is_finite() { v.round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 }; @@ -153,28 +156,30 @@ fn cnvpr(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.close("p:cNvPr"); } -fn nv_pr(w: &mut W, s: &Shape) { - match &s.ph { - Some(ph) => { - w.open0("p:nvPr"); - let ty = match ph.kind { - PhType::Obj => None, - k => Some(k.xml()), - }; - let sz = ph.size.as_deref().filter(|z| matches!(*z, "full" | "half" | "quarter")); - w.empty( - "p:ph", - A::new() - .o("type", ty) - .o("orient", ph.vertical.then_some("vert")) - .o("sz", sz) - .o("idx", (ph.idx != 0).then_some(ph.idx)) - .t("hasCustomPrompt", ph.has_custom_prompt), - ); - w.close("p:nvPr"); - } - None => w.empty0("p:nvPr"), +fn nv_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape) { + if s.ph.is_none() && s.tags.is_empty() { + w.empty0("p:nvPr"); + return; } + w.open0("p:nvPr"); + if let Some(ph) = &s.ph { + let ty = match ph.kind { + PhType::Obj => None, + k => Some(k.xml()), + }; + let sz = ph.size.as_deref().filter(|z| matches!(*z, "full" | "half" | "quarter")); + w.empty( + "p:ph", + A::new() + .o("type", ty) + .o("orient", ph.vertical.then_some("vert")) + .o("sz", sz) + .o("idx", (ph.idx != 0).then_some(ph.idx)) + .t("hasCustomPrompt", ph.has_custom_prompt), + ); + } + super::cust_data(w, x, o, &s.tags); + w.close("p:nvPr"); } /// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and @@ -202,7 +207,11 @@ fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) { } fn style(w: &mut W, st: &ShapeStyle) { - w.open0("p:style"); + style_tag(w, st, "p:style"); +} + +fn style_tag(w: &mut W, st: &ShapeStyle, tag: &str) { + w.open0(tag); for (tag, (idx, c)) in [("a:lnRef", &st.line_ref), ("a:fillRef", &st.fill_ref), ("a:effectRef", &st.effect_ref)] { w.open(tag, A::new().a("idx", idx)); dml::color(w, c); @@ -220,12 +229,16 @@ fn style(w: &mut W, st: &ShapeStyle) { } None => w.empty("a:fontRef", A::new().a("idx", idx)), } - w.close("p:style"); + w.close(tag); } /// `p:spPr` contents (also used for pictures and connectors). fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { - w.open0("p:spPr"); + sp_pr_tag(w, x, o, s, geom, "p:spPr"); +} + +fn sp_pr_tag(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool, tag: &str) { + w.open0(tag); if let Some(xf) = &s.xfrm { dml::xfrm(w, "a:xfrm", xf, None); } @@ -242,9 +255,11 @@ fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { dml::effects(w, fx); if let Some(raw) = &fx.raw3d { w.raw(raw); + } else if let Some(xml) = fx.bevel.as_deref().and_then(dml::bevel_sp3d) { + w.raw(&xml); } } - w.close("p:spPr"); + w.close(tag); } /// Write the children of a shape tree. @@ -284,7 +299,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth w.open0("p:cNvPicPr"); locks(w, "a:picLocks", s, A::new().a("noChangeAspect", "1")); w.close("p:cNvPicPr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvPicPr"); dml::blip_fill(w, x, o, "p:blipFill", fill); let mut s2 = s.clone(); @@ -297,6 +312,11 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:pic"); } + ShapeKind::Group { children, child } if s.diagram.is_some() && depth < 32 => { + if let Some(d) = &s.diagram { + diagram(w, x, o, cx, s, d, children, child); + } + } ShapeKind::Group { children, child } => { let id = cx.id_for(s); w.open0("p:grpSp"); @@ -305,7 +325,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth w.open0("p:cNvGrpSpPr"); locks(w, "a:grpSpLocks", s, A::new()); w.close("p:cNvGrpSpPr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvGrpSpPr"); w.open0("p:grpSpPr"); let xf = s.xfrm.unwrap_or(*child); @@ -340,7 +360,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:cNvCxnSpPr"); } - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvCxnSpPr"); let mut s2 = s.clone(); if matches!(s2.fill, Some(Fill::Background)) { @@ -365,17 +385,42 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } ShapeKind::Chart(c) => { let id = cx.id_for(s); - let rid = x.chart_rel(o, c); + // An unchanged Office 2016 chart goes back as itself, with a plain chart of the same + // data for readers without `cx1`, as PowerPoint writes it. + let ex = x.chartex_rel(o, c); + if let Some(exid) = &ex { + w.open("mc:AlternateContent", A::new().a("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006")); + w.open("mc:Choice", A::new().a("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").a("Requires", "cx1")); + frame_open(w, x, o, id, s); + w.open("a:graphicData", A::new().a("uri", super::chart::NS_CX)); + w.empty("cx:chart", A::new().a("xmlns:cx", super::chart::NS_CX).a("r:id", exid)); + w.close("a:graphicData"); + frame_close(w); + w.close("mc:Choice"); + w.open0("mc:Fallback"); + } + let mut plain = (**c).clone(); + plain.raw = plain.raw.filter(|r| !r.contains(super::chart::NS_CX)); + if ex.is_some() { + // The kept workbook belongs to the `chartEx` part; the fallback has none. + plain.kept = Default::default(); + } + let rid = x.chart_rel(o, &plain); frame_open(w, x, o, id, s); w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid)); w.close("a:graphicData"); frame_close(w); + if ex.is_some() { + w.close("mc:Fallback"); + w.close("mc:AlternateContent"); + } } ShapeKind::Media(m) => { let id = cx.id_for(s); media(w, x, o, id, s, m); } + ShapeKind::Model3d { media, poster } => write_model(w, x, o, cx, s, *media, *poster), ShapeKind::Ink { strokes } => { // Ink becomes a group of freeform lines. let id = cx.id_for(s); @@ -396,10 +441,151 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:grpSp"); } - ShapeKind::Opaque { xml, preview, label } => opaque(w, x, o, cx, s, xml, *preview, label), + ShapeKind::Opaque { xml, preview, label, parts } => opaque(w, x, o, cx, s, xml, *preview, label, parts), } } +fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option { + if x.p.media(media).is_none_or(|m| m.data.is_empty()) { + return None; + } + x.media_rel(o, media, crate::opc::RT_MODEL3D) +} + +fn write_model_part(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, rid: &str) { + w.open("p:contentPart", A::new().a("r:id", rid)); + w.open0("p:nvContentPartPr"); + cnvpr(w, x, o, id, s); + w.empty0("p:cNvContentPartPr"); + w.empty0("p:nvPr"); + w.close("p:nvContentPartPr"); + if let Some(xf) = &s.xfrm { + dml::xfrm(w, "p:xfrm", xf, None); + } + w.close("p:contentPart"); +} + +fn write_model( + w: &mut W, + x: &mut Exp, + o: &mut Out, + cx: &mut Ctx, + s: &Shape, + media: deckcraft_model::MediaId, + poster: Option, +) { + let Some(rid) = model_rel(x, o, media) else { + log::warn!("pptx: 3-D model '{}' has no embedded part", s.name); + return; + }; + let id = cx.id_for(s); + let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty())); + let Some(poster) = poster else { + write_model_part(w, x, o, id, s, &rid); + return; + }; + // Older readers draw the fallback picture; readers that know `am3d` use the model. + w.open("mc:AlternateContent", A::new().a("xmlns:mc", NS_MC)); + w.open("mc:Choice", A::new().a("xmlns:am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d").a("Requires", "am3d")); + write_model_part(w, x, o, id, s, &rid); + w.close("mc:Choice"); + w.open0("mc:Fallback"); + let pic = Shape { + id: ShapeId(0), + name: s.name.clone(), + xfrm: s.xfrm, + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: poster, ..Default::default() } }, + ..Default::default() + }; + let pid = cx.ids.fresh(); + shape_with_id(w, x, o, cx, &pic, pid); + w.close("mc:Fallback"); + w.close("mc:AlternateContent"); +} + +/// A SmartArt graphic: a graphic frame naming its data, layout, quick style and colour parts, +/// with the group's shapes as the cached drawing PowerPoint shows until it lays the diagram out. +#[allow(clippy::too_many_arguments)] +fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &deckcraft_model::Diagram, children: &[Shape], child: &Xfrm) { + use crate::dgm; + let id = cx.id_for(s); + let n = x.next_diagram(); + let frame = s.xfrm.unwrap_or(*child); + let name = |kind: &str| format!("ppt/diagrams/{kind}{n}.xml"); + // The drawing: the group's shapes in the frame's own coordinates. + let dname = name("drawing"); + let mut dout = Out::new(&dname); + let mut dw = W::new(); + dw.open( + "dsp:drawing", + A::new().a("xmlns:dgm", dgm::NS_DGM).a("xmlns:dsp", dgm::NS_DSP).a("xmlns:a", crate::opc::NS_A).a("xmlns:r", crate::opc::NS_R), + ); + dw.open0("dsp:spTree"); + dw.open0("dsp:nvGrpSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvGrpSpPr"); + dw.close("dsp:nvGrpSpPr"); + dw.empty0("dsp:grpSpPr"); + let (sx, sy) = (frame.w / child.w.max(1e-6), frame.h / child.h.max(1e-6)); + let mut k = 0; + let mut flat = vec![]; + walk(children, &mut |c, _| { + if matches!(c.kind, ShapeKind::Shape) { + flat.push(c.clone()); + } + }); + for mut c in flat.into_iter().take(10_000) { + if let Some(xf) = &mut c.xfrm { + *xf = Xfrm { x: (xf.x - child.x) * sx, y: (xf.y - child.y) * sy, w: xf.w * sx, h: xf.h * sy, ..*xf }; + } + k += 1; + dw.open("dsp:sp", A::new().a("modelId", format!("{{{:08X}-5D1A-4C0A-9E5B-{k:012X}}}", s.id.0))); + dw.open0("dsp:nvSpPr"); + dw.empty("dsp:cNvPr", A::new().a("id", 0).a("name", "")); + dw.empty0("dsp:cNvSpPr"); + dw.close("dsp:nvSpPr"); + sp_pr_tag(&mut dw, x, &mut dout, &c, true, "dsp:spPr"); + if let Some(st) = &c.style { + style_tag(&mut dw, st, "dsp:style"); + } + if let Some(tb) = &c.text { + dml::text_body(&mut dw, x, &mut dout, "dsp:txBody", tb, false); + } + dw.close("dsp:sp"); + } + dw.close("dsp:spTree"); + dw.close("dsp:drawing"); + x.pkg.xml_part(&dname, dgm::CT_DRAWING, dw.finish()); + x.pkg.rels(&dname, &dout.rels); + let rel = |o: &mut Out, kind: &str, part: &str| o.rels.add_new(kind, &crate::opc::relative(&o.name, part), false); + let drawing_rel = rel(o, dgm::RT_DRAWING, &dname); + let data = name("data"); + // Node pictures: on the data model's presentation points, through the data part's rels. + let mut mout = Out::new(&data); + let pics: Vec<(usize, String)> = + d.nodes.iter().enumerate().filter_map(|(k, n)| n.picture.and_then(|m| x.media_rel(&mut mout, m, "image")).map(|r| (k, r))).collect(); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel, &pics)); + x.pkg.rels(&data, &mout.rels); + let dm = rel(o, "diagramData", &data); + let layout = name("layout"); + let lo_xml = d.layout_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::layout_xml(&d.kind)); + x.pkg.xml_part(&layout, dgm::CT_LAYOUT, lo_xml); + let lo = rel(o, "diagramLayout", &layout); + let style = name("quickStyle"); + x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::style_xml(&d.style))); + let qs = rel(o, "diagramQuickStyle", &style); + let colors = name("colors"); + x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::colors_xml(&d.colors))); + let cs = rel(o, "diagramColors", &colors); + let mut fs = s.clone(); + fs.xfrm = Some(frame); + frame_open(w, x, o, id, &fs); + w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/diagram")); + w.empty("dgm:relIds", A::new().a("xmlns:dgm", dgm::NS_DGM).a("r:dm", dm).a("r:lo", lo).a("r:qs", qs).a("r:cs", cs)); + w.close("a:graphicData"); + frame_close(w); +} + fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { let pts: Vec<(f64, f64)> = st.points.iter().filter(|p| p.0.is_finite() && p.1.is_finite()).map(|p| (p.0, p.1)).collect(); let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1))); @@ -408,6 +594,15 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { return None; } let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); + // PPTX keeps one line width. A pressured stroke exports at its mean sample width; + // a legacy flat `0.5` stroke stays at the pen width. + let legacy = deckcraft_model::ink_pressure_is_legacy(&st.points); + let width = if legacy { + st.width + } else { + let n = st.points.len().max(1) as f64; + st.points.iter().map(|p| deckcraft_model::ink_sample_width(st.width, p.2, false)).sum::() / n + }; let mut d = String::new(); for (k, (px, py)) in pts.iter().enumerate() { d.push_str(if k == 0 { "M " } else { "L " }); @@ -426,7 +621,7 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { line: Some(deckcraft_model::Line { cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), - ..deckcraft_model::Line::solid(c, st.width.max(0.25)) + ..deckcraft_model::Line::solid(c, width.max(0.25)) }), ..Default::default() }) @@ -448,7 +643,7 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { } else { w.empty("p:cNvSpPr", cnv); } - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvSpPr"); let tmp; let sref = if matches!(s.fill, Some(Fill::Background)) { @@ -464,7 +659,15 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { style(w, st); } if let Some(tb) = &s.text { - dml::text_body(w, x, o, "p:txBody", tb, math); + // A shape's text without its own anchor sits in the middle in the model; a file without + // one would put it at the top, so say so. + if s.ph.is_none() && !s.text_box && tb.body.anchor.is_none() { + let mut tb = tb.clone(); + tb.body.anchor = Some(deckcraft_model::text::Anchor::Middle); + dml::text_body(w, x, o, "p:txBody", &tb, math); + } else { + dml::text_body(w, x, o, "p:txBody", tb, math); + } } w.close("p:sp"); } @@ -476,7 +679,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.open0("p:cNvGraphicFramePr"); w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked)); w.close("p:cNvGraphicFramePr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvGraphicFramePr"); let xf = s.xfrm.unwrap_or_default(); let mut xf0 = xf; @@ -582,6 +785,9 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_ action: deckcraft_model::text::Action::PlayMedia, tooltip: String::new(), highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, }); } cnvpr(w, x, o, id, &s2); @@ -631,13 +837,32 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_ } #[allow(clippy::too_many_arguments)] -fn opaque(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, xml: &str, preview: Option, label: &str) { - // Kept XML that references no relationships can be written back as it was. +fn opaque( + w: &mut W, + x: &mut Exp, + o: &mut Out, + cx: &mut Ctx, + s: &Shape, + xml: &str, + preview: Option, + label: &str, + parts: &[(String, String, deckcraft_model::MediaId)], +) { + // Kept XML is written back as it was when every part it references was kept too, with those + // parts re-added under this slide's relationship ids. if let Ok(doc) = crate::xml::parse(xml.as_bytes()) - && !crate::read::has_rel_refs(&doc.root) && matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp") + && let Some(ids) = crate::read::rel_refs(&doc.root) + .iter() + .map(|r| { + let (_, kind, m) = parts.iter().find(|(id, ..)| id == r)?; + Some((r.clone(), x.media_rel(o, *m, kind)?)) + }) + .collect::>>() { let mut root = doc.root; + set_rel_ids(&mut root, &ids, 0); + ole_fallback(&mut root, x, o, preview); let id = cx.id_for(s); set_cnvpr_id(&mut root, id); w.raw(&root.to_xml()); @@ -687,6 +912,52 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i } } +/// A legacy OLE object finds its preview in the slide's VML drawing through `spid`, and PowerPoint +/// won't open the slide without it. We don't write that drawing, so write PowerPoint's own +/// fallback form instead: no `spid`, and the preview as a `p:pic`. +fn ole_fallback(root: &mut crate::xml::El, x: &mut Exp, o: &mut Out, preview: Option) { + let xfrm = root.child("xfrm").map(|t| { + let at = |e: &str, a: &str| t.child(e).and_then(|c| c.attr(a)).unwrap_or("0").to_string(); + (at("off", "x"), at("off", "y"), at("ext", "cx"), at("ext", "cy")) + }); + let Some(ole) = root + .elements_mut() + .find(|e| e.is("graphic")) + .and_then(|g| g.elements_mut().find(|e| e.is("graphicData"))) + .and_then(|d| d.elements_mut().find(|e| e.is("oleObj"))) + else { + return; + }; + if ole.child("pic").is_some() || ole.attr("spid").is_none() { + return; + } + ole.attrs.retain(|(k, _)| k != "spid"); + let rel = preview.and_then(|m| x.media_rel(o, m, "image")).unwrap_or_else(|| x.placeholder_rel(o)); + let (ox, oy, cx, cy) = xfrm.unwrap_or_default(); + let pic = format!( + r#""# + ); + if let Ok(d) = crate::xml::parse(pic.as_bytes()) { + ole.children.push(crate::xml::Node::El(d.root)); + } +} + +fn set_rel_ids(e: &mut crate::xml::El, ids: &std::collections::HashMap, depth: usize) { + if depth > crate::xml::MAX_DEPTH { + return; + } + for (k, v) in &mut e.attrs { + if crate::read::is_rel_attr(k, v) + && let Some(new) = ids.get(v.as_str()) + { + v.clone_from(new); + } + } + for c in e.elements_mut() { + set_rel_ids(c, ids, depth + 1); + } +} + fn set_cnvpr_id(el: &mut crate::xml::El, id: u32) { fn rec(e: &mut crate::xml::El, id: u32, depth: usize) -> bool { if depth > 8 { diff --git a/crates/pptx/src/write/theme.rs b/crates/pptx/src/write/theme.rs index d83e90f9..48039420 100644 --- a/crates/pptx/src/write/theme.rs +++ b/crates/pptx/src/write/theme.rs @@ -83,6 +83,8 @@ pub fn theme_xml(x: &mut Exp, o: &mut Out, t: &Theme) -> Vec { dml::effects(&mut w, e); if let Some(raw) = &e.raw3d { w.raw(raw); + } else if let Some(xml) = e.bevel.as_deref().and_then(dml::bevel_sp3d) { + w.raw(&xml); } w.close("a:effectStyle"); } diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index aa577ca7..ec4bffd2 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -16,14 +16,19 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) { 601..=900 => "med", _ => "slow", }; - let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio")); + let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default()))); let snd = |w: &mut W| { - if let Some(r) = &sound { + if let Some((r, name)) = &sound { w.open0("p:sndAc"); - w.open0("p:stSnd"); - w.empty("p:snd", A::new().a("r:embed", r).a("name", "sound.wav")); + w.open("p:stSnd", A::new().t("loop", t.sound_loop)); + let name = if name.is_empty() { "sound.wav" } else { name.as_str() }; + w.empty("p:snd", A::new().a("r:embed", r).a("name", name)); w.close("p:stSnd"); w.close("p:sndAc"); + } else if t.stop_sound { + w.open0("p:sndAc"); + w.empty0("p:endSnd"); + w.close("p:sndAc"); } }; let mut effect = W::frag(); @@ -208,14 +213,14 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "randombar(horizontal)" } } - "blinds" => { + "blinds" | "blindsOut" => { if option == "vert" { "blinds(vertical)" } else { "blinds(horizontal)" } } - "box" => { + "box" | "boxOut" => { if option == "out" { "box(out)" } else { @@ -229,7 +234,7 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "circle(in)" } } - "diamond" => { + "diamond" | "diamondOut" => { if option == "out" { "diamond(out)" } else { @@ -246,7 +251,7 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "plusOut" => "plus(out)", _ => "circle(in)", }, - "checkerboard" => { + "checkerboard" | "checkerboardOut" => { if option == "down" { "checkerboard(down)" } else { @@ -261,13 +266,13 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "8" => "wheel(8)", _ => "wheel(1)", }, - "strips" => match option { + "strips" | "stripsOut" => match option { "ru" => "strips(upRight)", "ld" => "strips(downLeft)", "rd" => "strips(downRight)", _ => "strips(upLeft)", }, - "peek" => match option { + "peek" | "peekOut" => match option { "t" => "slide(fromTop)", "l" => "slide(fromLeft)", "r" => "slide(fromRight)", @@ -328,7 +333,12 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { anim_prop(w, ids, e, dur, "ppt_x", "#ppt_x", fx); anim_prop(w, ids, e, dur, "ppt_y", "#ppt_y", fy); } - "zoomOut" | "shrinkTurn" => { + "floatOut" | "sinkDown" => { + let to = if a.effect == "sinkDown" || a.option == "d" { "#ppt_y+.1" } else { "#ppt_y-.1" }; + anim_prop(w, ids, e, dur, "ppt_y", "#ppt_y", to); + anim_effect(w, ids, e, dur, "fade", true); + } + "zoomOut" | "shrinkTurn" | "spinnerOut" | "contract" => { anim_prop(w, ids, e, dur, "ppt_w", "#ppt_w", "0"); anim_prop(w, ids, e, dur, "ppt_h", "#ppt_h", "0"); anim_effect(w, ids, e, dur, "fade", true); @@ -354,6 +364,25 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { w.close("p:to"); w.close("p:set"); } + "colorPulse" => { + // As PowerPoint writes it: text and fill go to the colour and back (autoRev). The behaviour + // duration is the one import reads as the effect duration. + let c = a.color.clone().unwrap_or(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xC0, 0x00, 0x00))); + for (attr, child) in [("style.color", true), ("fillcolor", false)] { + w.open("p:animClr", A::new().a("clrSpc", "rgb").a("dir", "cw")); + w.open("p:cBhvr", A::new().o("override", child.then_some("childStyle"))); + w.empty("p:cTn", A::new().a("id", ids.next()).a("dur", dur.max(1)).a("autoRev", "1").a("fill", "remove")); + tgt(w, e.spid, e.paragraph); + w.open0("p:attrNameLst"); + w.elt("p:attrName", attr); + w.close("p:attrNameLst"); + w.close("p:cBhvr"); + w.open0("p:to"); + super::dml::color(w, &c); + w.close("p:to"); + w.close("p:animClr"); + } + } "fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "darken" | "lighten" => { let attr = match a.effect.as_str() { "fontColor" => "style.color", @@ -425,7 +454,11 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo }; let _ = first_in_click; let preset = crate::maps::preset_for(a); - let sub = crate::maps::subtype_from_option(&a.effect, &a.option).or(a.preset_subtype.filter(|_| a.preset_id == Some(preset))).unwrap_or(0); + let sub = if preset == 47 { + 0 + } else { + crate::maps::subtype_from_option(&a.effect, &a.option).or(a.preset_subtype.filter(|_| a.preset_id == Some(preset))).unwrap_or(0) + }; let repeat = match a.repeat { u32::MAX => Some("indefinite".to_string()), 0 | 1 => None, @@ -433,6 +466,9 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo }; let accel = (a.smooth_start > 0.0).then(|| (a.smooth_start.clamp(0.0, 1.0) * 100_000.0).round() as i64); let decel = (a.smooth_end > 0.0).then(|| (a.smooth_end.clamp(0.0, 1.0) * 100_000.0).round() as i64); + // `bounceEnd` is a positive percentage (100000 = 100%), same scale as accel/decel. The model stores 0–1. + let bounce = + (a.bounce_end.is_finite() && a.bounce_end > 0.0).then(|| (a.bounce_end.clamp(0.0, 1.0) * 100_000.0).round() as i64).filter(|n| *n > 0); w.open0("p:par"); w.open( "p:cTn", @@ -444,8 +480,9 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo .o("repeatCount", repeat) .o("accel", accel) .o("decel", decel) + .o("bounceEnd", bounce) .t("autoRev", a.auto_reverse) - .a("fill", "hold") + .a("fill", if a.rewind { "remove" } else { "hold" }) .a("grpId", e.grp) .a("nodeType", node), ); diff --git a/crates/pptx/src/xml.rs b/crates/pptx/src/xml.rs index 60a94817..9484863a 100644 --- a/crates/pptx/src/xml.rs +++ b/crates/pptx/src/xml.rs @@ -171,19 +171,6 @@ impl El { write_el(self, &mut s, 0); s } - /// Remove descendant elements with any of these local names (bounded). - pub fn remove_all(&mut self, locals: &[&str]) { - fn rec(e: &mut El, locals: &[&str], depth: usize) { - if depth > MAX_DEPTH { - return; - } - e.children.retain(|c| !matches!(c, Node::El(x) if locals.contains(&x.local()))); - for c in e.elements_mut() { - rec(c, locals, depth + 1); - } - } - rec(self, locals, 0) - } } fn write_el(e: &El, s: &mut String, depth: usize) { @@ -282,13 +269,13 @@ pub fn parse(bytes: &[u8]) -> Result { Event::Text(t) => { if let Some(p) = stack.last_mut() { let s = t.decode().map(|c| c.into_owned()).unwrap_or_default(); - push_text(p, &s); + push_text(p, &line_ends(s)); } } Event::CData(t) => { if let Some(p) = stack.last_mut() { let s = String::from_utf8_lossy(&t).into_owned(); - push_text(p, &s); + push_text(p, &line_ends(s)); } } Event::GeneralRef(r) => { @@ -324,6 +311,12 @@ pub fn parse(bytes: &[u8]) -> Result { Ok(Doc { root }) } +/// XML 1.0 §2.11: a literal CR LF or lone CR in the document reads as LF. A ` ` reference is +/// not literal and reaches `push_text` through `GeneralRef`, so it stays a CR. +fn line_ends(s: String) -> String { + if s.contains('\r') { s.replace("\r\n", "\n").replace('\r', "\n") } else { s } +} + fn push_text(p: &mut El, s: &str) { if s.is_empty() { return; @@ -340,8 +333,10 @@ fn start_el(b: &quick_xml::events::BytesStart, reader: &quick_xml::Reader<&[u8]> let mut attrs = vec![]; for a in b.attributes().with_checks(false).flatten() { let k = String::from_utf8_lossy(a.key.as_ref()).into_owned(); - let v = - a.decode_and_unescape_value(reader.decoder()).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned()); + let v = a + .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, reader.decoder()) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned()); attrs.push((k, v)); } El { name, attrs, children: vec![] } @@ -504,6 +499,13 @@ impl W { mod tests { use super::*; + #[test] + fn literal_cr_lf_and_lone_cr_read_as_lf_but_char_ref_stays() { + let d = parse(b"one\r\ntwo\rthree four").expect("parse"); + let t: Vec = d.root.children_named("t").map(|t| t.text()).collect(); + assert_eq!(t, vec!["one\ntwo\nthree\rfour".to_string(), "x\ny".to_string()]); + } + #[test] fn parse_basic_and_entities() { let d = parse(b"A & B A <") diff --git a/crates/pptx/tests/anchor.rs b/crates/pptx/tests/anchor.rs new file mode 100644 index 00000000..72af2436 --- /dev/null +++ b/crates/pptx/tests/anchor.rs @@ -0,0 +1,74 @@ +//! Text anchoring of shapes without an `anchor` of their own: the model centres a shape's text, +//! a file without one puts it at the top (or where the theme's shape defaults say). + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::text::Anchor; +use deckcraft_model::{Presentation, ShapeKind}; +use zip::write::SimpleFileOptions; + +fn sample() -> Presentation { + let mut s = deckcraft_engine::Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + s.doc().expect("doc").doc.as_ref().clone() +} + +/// Every part of `bytes` with `edit` applied to the XML ones. +fn rewrite(bytes: &[u8], edit: impl Fn(&str, String) -> String) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).expect("read"); + if name.ends_with(".xml") { + data = edit(&name, String::from_utf8(data).expect("utf8")).into_bytes(); + } + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +/// Anchors of the shapes with text that aren't placeholders, text boxes or tables. +fn anchors(p: &Presentation) -> Vec> { + p.slides + .iter() + .flat_map(|s| s.shapes.iter()) + .filter(|s| s.ph.is_none() && !s.text_box && !matches!(s.kind, ShapeKind::Table(_))) + .filter_map(|s| s.text.as_ref().map(|t| t.body.anchor)) + .collect() +} + +#[test] +fn shapes_keep_their_middle_anchor_and_files_without_one_anchor_at_the_top() { + let p = sample(); + assert!(anchors(&p).contains(&None), "the sample has shapes with the model's default anchor"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + + // Written explicitly, so PowerPoint (which reads a missing anchor as top) centres them too. + let back = deckcraft_pptx::import(&bytes).expect("import"); + let a = anchors(&back); + assert_eq!(a, anchors(&p), "the middle anchor survives (as the model's default)"); + + // The same file without the anchors: their text sits at the top. + let bare = rewrite(&bytes, |n, x| if n.starts_with("ppt/slides/slide") { x.replace(r#" anchor="ctr""#, "") } else { x }); + let a = anchors(&deckcraft_pptx::import(&bare).expect("import")); + assert!(a.iter().all(|x| *x == Some(Anchor::Top)), "{a:?}"); + + // ...unless the theme's shape defaults centre them, as Office's themes do. + let themed = rewrite(&bare, |n, x| { + if n.starts_with("ppt/theme/") { + x.replace( + "", + r#""#, + ) + } else { + x + } + }); + let a = anchors(&deckcraft_pptx::import(&themed).expect("import")); + assert!(a.iter().all(|x| x.is_none() || *x == Some(Anchor::Middle)), "{a:?}"); + assert!(a.iter().filter(|x| x.is_none()).count() >= 3, "{a:?}"); +} diff --git a/crates/pptx/tests/chart_embed.rs b/crates/pptx/tests/chart_embed.rs new file mode 100644 index 00000000..2549cdb6 --- /dev/null +++ b/crates/pptx/tests/chart_embed.rs @@ -0,0 +1,330 @@ +//! An unchanged chart keeps the embedded workbook its `c:externalData` points at (#335). + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_geom::Xfrm; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, defaults}; + +const WORKBOOK: &[u8] = b"chart-workbook-bytes-v1"; +const EMBED: &str = "ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx"; + +fn chart_pptx() -> Vec { + let mut p = defaults::new_presentation(None); + p.slides.clear(); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); + let mut shape = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; + shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))); + slide.shapes.push(shape); + p.slides.push(Arc::new(slide)); + deckcraft_pptx::export(&p).expect("export") +} + +/// Add an embedded workbook and point the chart part at it, the way PowerPoint writes a chart. +fn with_workbook(bytes: &[u8]) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let mut chart_part = None; + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") { + let xml = String::from_utf8(data).expect("chart xml"); + assert!(xml.contains(""), "{name}"); + data = xml + .replacen("", r#""#, 1) + .into_bytes(); + chart_part = Some(name.clone()); + } else if name == "[Content_Types].xml" { + let xml = String::from_utf8(data).expect("content types"); + data = xml + .replacen( + "", + r#""#, + 1, + ) + .into_bytes(); + } + out.start_file(&name, opts).expect("start"); + out.write_all(&data).expect("write"); + } + let chart_part = chart_part.expect("chart part"); + let rels = format!("ppt/charts/_rels/{}.rels", chart_part.rsplit('/').next().unwrap_or("chart1.xml")); + let rels_xml = br#" +"#; + out.start_file(rels, opts).expect("rels"); + out.write_all(rels_xml).expect("rels bytes"); + out.start_file(EMBED, opts).expect("embed"); + out.write_all(WORKBOOK).expect("workbook"); + out.finish().expect("finish").into_inner() +} + +fn parts(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = vec![]; + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + out.push((name, data)); + } + out +} + +fn chart_of(bytes: &[u8]) -> deckcraft_model::Chart { + let p = deckcraft_pptx::import(bytes).expect("import"); + p.slides + .iter() + .flat_map(|s| s.shapes.iter()) + .find_map(|s| match &s.kind { + ShapeKind::Chart(c) if s.name == "Pie" => Some((**c).clone()), + _ => None, + }) + .expect("pie chart") +} + +#[test] +fn an_unchanged_chart_keeps_its_embedded_workbook() { + let src = with_workbook(&chart_pptx()); + let chart = chart_of(&src); + assert_eq!(chart.kept.parts.iter().filter(|p| p.name == EMBED).count(), 1); + assert_eq!(chart.kept.parts.iter().find(|p| p.name == EMBED).map(|p| p.bytes.as_slice()), Some(WORKBOOK)); + + let saved = deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export"); + let saved_parts = parts(&saved); + // Kept parts are written under fresh names, so they can't land on a part the writer makes. + let workbook = saved_parts.iter().find(|(n, _)| n == "ppt/embeddings/kept1.xlsx").map(|(_, d)| d.as_slice()); + assert_eq!(workbook, Some(WORKBOOK), "the embedding part is dropped on save"); + let rels = saved_parts + .iter() + .find(|(n, _)| n.starts_with("ppt/charts/_rels/chart") && n.ends_with(".xml.rels")) + .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) + .expect("chart relationships"); + assert!(rels.contains(r#"Id="rId1""#) && rels.contains("../embeddings/kept1.xlsx"), "{rels}"); + assert!(!rels.contains("slide1.xml"), "a chart relationship must not replace the slide: {rels}"); + let slide = saved_parts.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").map(|(_, d)| String::from_utf8_lossy(d).into_owned()).expect("slide"); + assert!(slide.contains("sld"), "{slide}"); + assert!(!slide.as_bytes().windows(WORKBOOK.len()).any(|w| w == WORKBOOK)); + let xml = saved_parts + .iter() + .find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml")) + .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) + .expect("chart xml"); + assert!(xml.contains("externalData"), "{xml}"); +} + +#[test] +fn an_edited_chart_does_not_write_a_stale_workbook() { + let src = with_workbook(&chart_pptx()); + let mut p = deckcraft_pptx::import(&src).expect("import"); + let slide = Arc::make_mut(p.slides.get_mut(0).expect("slide")); + let chart = slide.shapes.iter_mut().find_map(|s| match &mut s.kind { + ShapeKind::Chart(c) if s.name == "Pie" => Some(c), + _ => None, + }); + let chart = chart.expect("pie"); + let value = chart.series.get_mut(0).and_then(|s| s.values.get_mut(0)).expect("value"); + *value = Some(99.0); + let saved = parts(&deckcraft_pptx::export(&p).expect("export")); + assert!(saved.iter().all(|(n, _)| !n.starts_with("ppt/embeddings/")), "a stale workbook was written after the chart changed"); + let xml = saved + .iter() + .find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml")) + .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) + .expect("chart xml"); + assert!(!xml.contains("externalData"), "{xml}"); +} + +fn png(rgb: [u8; 3]) -> Vec { + let img = image::RgbImage::from_pixel(4, 4, image::Rgb(rgb)); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +/// Rewrite `src`: `edit` may change any entry (name, bytes → new bytes), then `extra` entries are added. +fn rewrite(src: &[u8], edit: impl Fn(&str, Vec) -> Vec, extra: &[(&str, &[u8])]) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(src)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + let data = edit(&name, data); + out.start_file(&name, opts).expect("start"); + out.write_all(&data).expect("write"); + } + for (name, data) in extra { + out.start_file(*name, opts).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn unique_names(parts: &[(String, Vec)]) { + let mut seen = std::collections::HashSet::new(); + for (n, _) in parts { + assert!(seen.insert(n.to_ascii_lowercase()), "duplicate zip entry {n}"); + } +} + +fn text_of(parts: &[(String, Vec)], name: &str) -> String { + parts.iter().find(|(n, _)| n == name).map(|(_, d)| String::from_utf8_lossy(d).into_owned()).unwrap_or_else(|| panic!("no {name}")) +} + +const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// A chart whose relationships reach parts named like the ones the writer generates +/// (`ppt/media/image1.png`, `ppt/drawings/…`) must not collide with them on save: no duplicate +/// zip entries, and the slide's own picture keeps its bytes. +#[test] +fn kept_chart_parts_never_collide_with_written_parts() { + let picture = png([10, 200, 30]); + let mut p = defaults::new_presentation(None); + p.slides.clear(); + let img = p.add_media("image1.png", "image/png", picture.clone()).expect("media"); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); + // The chart comes first, so its kept parts are written before the picture. + let mut chart = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; + chart.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))); + slide.shapes.push(chart); + let mut pic = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pic".into(), xfrm: Some(Xfrm::new(500.0, 40.0, 80.0, 80.0)), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: img, ..Default::default() } }; + slide.shapes.push(pic); + p.slides.push(Arc::new(slide)); + let base = deckcraft_pptx::export(&p).expect("export"); + assert!(parts(&base).iter().any(|(n, d)| n == "ppt/media/image1.png" && *d == picture)); + + // The chart's own relationships reach the slide picture's part and a drawing whose + // relationships reach it too (and, hostile, the slide). + let chart_rels = format!( + r#""# + ); + let drawing_rels = format!( + r#""# + ); + let drawing = br#""#; + let src = rewrite( + &base, + |name, data| { + if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") { + String::from_utf8(data) + .expect("chart") + .replacen("", r#""#, 1) + .into_bytes() + } else { + data + } + }, + &[ + ("ppt/charts/_rels/chart1.xml.rels", chart_rels.as_bytes()), + ("ppt/drawings/drawing1.xml", drawing), + ("ppt/drawings/_rels/drawing1.xml.rels", drawing_rels.as_bytes()), + (EMBED, WORKBOOK), + ], + ); + + let saved = parts(&deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export")); + unique_names(&saved); + let picture_parts: Vec<&String> = saved.iter().filter(|(_, d)| *d == picture).map(|(n, _)| n).collect(); + assert!(picture_parts.iter().any(|n| *n == "ppt/media/image1.png"), "{picture_parts:?}"); + let slide_rels = text_of(&saved, "ppt/slides/_rels/slide1.xml.rels"); + assert!(slide_rels.contains("../media/image1.png"), "{slide_rels}"); + + // The chart's relationships point at the renamed parts, which hold the kept bytes. + let rels = text_of(&saved, "ppt/charts/_rels/chart1.xml.rels"); + assert!(!rels.contains("image1.png") && !rels.contains("drawing1.xml") && !rels.contains("Worksheet1"), "{rels}"); + let target = |rels: &str, id: &str| -> String { + let at = rels.find(&format!(r#"Id="{id}""#)).unwrap_or_else(|| panic!("{id} in {rels}")); + let rest = &rels[at..]; + let t = rest.find(r#"Target=""#).expect("target") + 8; + rest[t..].split('"').next().unwrap_or("").to_string() + }; + let resolve = |from_dir: &str, t: &str| format!("ppt/{}", t.trim_start_matches("../").trim_start_matches(from_dir)); + let img_part = resolve("", &target(&rels, "rId3")); + assert!(img_part.starts_with("ppt/media/kept"), "{img_part}"); + assert!(saved.iter().any(|(n, d)| *n == img_part && *d == picture), "{img_part}"); + let drawing_part = resolve("", &target(&rels, "rId2")); + assert!(saved.iter().any(|(n, d)| *n == drawing_part && d.as_slice() == drawing.as_slice()), "{drawing_part}"); + let wb = resolve("", &target(&rels, "rId1")); + assert!(saved.iter().any(|(n, d)| *n == wb && d.as_slice() == WORKBOOK), "{wb}"); + + // The drawing's own relationships follow the rename; the one to the slide is dropped. + let file = drawing_part.rsplit('/').next().unwrap_or(""); + let drels = text_of(&saved, &format!("ppt/drawings/_rels/{file}.rels")); + assert_eq!(resolve("", &target(&drels, "rId1")), img_part, "{drels}"); + assert!(!drels.contains("slide"), "{drels}"); + + // And the result reads back with the same kept parts. + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&saved_zip(&saved)).expect("reimport")).expect("export again"); + unique_names(&parts(&again)); +} + +fn saved_zip(parts: &[(String, Vec)]) -> Vec { + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (n, d) in parts { + out.start_file(n.as_str(), opts).expect("start"); + out.write_all(d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +/// An unchanged Office 2016 chart keeps its embedded workbook too; its plain-chart fallback +/// carries none. +#[test] +fn an_unchanged_chartex_keeps_its_embedded_workbook() { + let mut p = defaults::new_presentation(None); + p.slides.clear(); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); + let mut shape = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; + shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Waterfall))); + slide.shapes.push(shape); + p.slides.push(Arc::new(slide)); + let base = deckcraft_pptx::export(&p).expect("export"); + assert!(parts(&base).iter().any(|(n, _)| n == "ppt/charts/chartEx1.xml")); + let rels = format!( + r#""# + ); + let src = rewrite( + &base, + |name, data| { + if name == "ppt/charts/chartEx1.xml" { + String::from_utf8(data) + .expect("chartex") + .replacen("", r#""#, 1) + .into_bytes() + } else { + data + } + }, + &[("ppt/charts/_rels/chartEx1.xml.rels", rels.as_bytes()), (EMBED, WORKBOOK)], + ); + let chart = chart_of(&src); + assert_eq!(chart.kind, ChartType::Waterfall); + assert!(chart.kept.parts.iter().any(|p| p.bytes == WORKBOOK)); + + let saved = parts(&deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export")); + unique_names(&saved); + let xml = text_of(&saved, "ppt/charts/chartEx1.xml"); + assert!(xml.contains("externalData"), "{xml}"); + let rels = text_of(&saved, "ppt/charts/_rels/chartEx1.xml.rels"); + assert!(rels.contains("../embeddings/kept1.xlsx"), "{rels}"); + assert!(saved.iter().any(|(n, d)| n == "ppt/embeddings/kept1.xlsx" && d.as_slice() == WORKBOOK)); + assert!(saved.iter().all(|(n, _)| n != "ppt/charts/_rels/chart1.xml.rels"), "the fallback chart has no workbook"); + assert!(!text_of(&saved, "ppt/charts/chart1.xml").contains("externalData")); +} diff --git a/crates/pptx/tests/comments.rs b/crates/pptx/tests/comments.rs new file mode 100644 index 00000000..c3befe09 --- /dev/null +++ b/crates/pptx/tests/comments.rs @@ -0,0 +1,208 @@ +//! Comment threads through PPTX: the modern part keeps replies and resolved state, the legacy +//! part stays readable, and a package with both imports every thread once. + +use std::io::Read; +use std::sync::Arc; + +use deckcraft_model::{Comment, Presentation, defaults}; + +fn deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let s = Arc::make_mut(&mut p.slides[0]); + s.comments = vec![ + Comment { + author: "Ada".into(), + initials: "AL".into(), + text: "Top thread\nsecond line".into(), + date: "2025-03-04T05:06:07Z".into(), + x: 90.0, + y: 45.0, + resolved: true, + replies: vec![ + Comment { author: "Bob".into(), text: "A & more".into(), date: "2025-03-04T06:00:00Z".into(), ..Default::default() }, + Comment { + author: "Ada".into(), + text: "Another reply".into(), + date: "2025-03-04T06:30:00Z".into(), + resolved: true, + ..Default::default() + }, + ], + shape: None, + }, + Comment { + author: "Bob".into(), + initials: "BC".into(), + text: "Open thread".into(), + date: "2025-03-05T01:02:03Z".into(), + x: 120.0, + y: 60.0, + resolved: false, + replies: vec![], + shape: None, + }, + ]; + p +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = String::new(); + z.by_name(name).expect("part").read_to_string(&mut out).expect("utf8"); + out +} + +/// Rewrite the package, dropping or replacing entries by name. +fn rewrite(bytes: &[u8], drop: &[&str]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + if drop.iter().any(|d| f.name() == *d) { + continue; + } + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start"); + std::io::copy(&mut f, &mut out).expect("copy"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn threads_and_resolved_round_trip() { + let p = deck(); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let (a, b) = (&p.slides[0].comments, &q.slides[0].comments); + assert_eq!(b.len(), a.len(), "comments duplicated: {b:?}"); + assert_eq!(b[0].author, "Ada"); + assert_eq!(b[0].initials, "AL"); + assert_eq!(b[0].text, "Top thread\nsecond line"); + assert_eq!(b[0].date, "2025-03-04T05:06:07Z"); + assert!(b[0].resolved); + assert_eq!((b[0].x, b[0].y), (90.0, 45.0)); + assert_eq!(b[0].replies.len(), 2); + assert_eq!(b[0].replies[0].author, "Bob"); + assert_eq!(b[0].replies[0].text, "A & more"); + assert_eq!(b[0].replies[0].date, "2025-03-04T06:00:00Z"); + assert_eq!(b[0].replies[1].text, "Another reply"); + assert!(!b[0].replies[0].resolved); + assert!(b[0].replies[1].resolved); + assert_eq!(b[1].author, "Bob"); + assert!(!b[1].resolved); + assert_eq!(b[1].text, "Open thread"); +} + +#[test] +fn package_carries_both_parts_and_the_slide_anchor() { + let bytes = deckcraft_pptx::export(&deck()).expect("export"); + let modern = part(&bytes, "ppt/comments/modernComment1.xml"); + assert!(modern.contains("p188:cmLst"), "{modern}"); + // The thread and the second reply are resolved; the first reply is not. + assert_eq!(modern.matches(r#"status="resolved""#).count(), 2, "{modern}"); + assert!(modern.contains("p188:replyLst") && modern.contains("A <reply> & more"), "{modern}"); + assert!(modern.contains(r#"second line"#), "{modern}"); + let legacy = part(&bytes, "ppt/comments/comment1.xml"); + assert!(legacy.contains(" +"#; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&trimmed)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start"); + if f.name() == "ppt/slides/slide1.xml" { + let mut xml = String::new(); + f.read_to_string(&mut xml).expect("utf8"); + if let Some(start) = xml.find("") { + let end = xml.find("").map(|e| e + "".len()).unwrap_or(xml.len()); + xml.replace_range(start..end, ""); + } + std::io::Write::write_all(&mut out, xml.as_bytes()).expect("write"); + } else { + std::io::copy(&mut f, &mut out).expect("copy"); + } + } + out.start_file("ppt/slides/_rels/slide1.xml.rels", zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, legacy_rels.as_bytes()).expect("write"); + let legacy_pkg = out.finish().expect("finish").into_inner(); + + let q = deckcraft_pptx::import(&legacy_pkg).expect("import"); + let b = &q.slides[0].comments; + // The legacy part flattened the two replies into siblings: four top-level comments. + assert_eq!(b.len(), 4, "{b:?}"); + assert_eq!(b[0].text, "Top thread\nsecond line"); + // The legacy format has no resolved state — exactly what the modern part adds. + assert!(!b[0].resolved); + assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "legacy positions are 1/8 pt"); + assert!(b.iter().all(|c| c.replies.is_empty())); +} + +/// A modern-only package (no legacy part) imports threads and EMU positions. +#[test] +fn modern_only_package_imports() { + let bytes = deckcraft_pptx::export(&deck()).expect("export"); + let trimmed = rewrite(&bytes, &["ppt/comments/comment1.xml"]); + let q = deckcraft_pptx::import(&trimmed).expect("import"); + let b = &q.slides[0].comments; + assert_eq!(b.len(), 2, "{b:?}"); + assert_eq!(b[0].replies.len(), 2); + assert!(b[0].resolved); + assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "modern positions are EMU"); +} diff --git a/crates/pptx/tests/corpus.rs b/crates/pptx/tests/corpus.rs new file mode 100644 index 00000000..4f8a2b00 --- /dev/null +++ b/crates/pptx/tests/corpus.rs @@ -0,0 +1,385 @@ +//! Owned corpus: decks this repository authors, then open → save → reopen. +//! +//! Each package is built here (nothing from another product). The check compares the text in the +//! package and the content parts. A second save must come out the same as the first. + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::text::{Action, Hyperlink, Paragraph, Run, RunProps, TextBody}; +use deckcraft_model::{Comment, Geom, PhType, Presentation, ShapeKind, Slide, defaults}; + +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let img = image::RgbImage::from_pixel(w, h, image::Rgb(rgb)); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> deckcraft_model::Shape { + deckcraft_model::Shape { + id: deckcraft_model::ShapeId(p.alloc_id().expect("id")), + name: name.into(), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w, h)), + ..Default::default() + } +} + +fn new_slide(p: &mut Presentation, title: &str) -> Slide { + let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout).expect("slide"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(title)); + } + s +} + +/// A short talk: title, notes, a link, a comment, a picture, a table and a chart. +fn talk() -> Presentation { + let mut p = defaults::new_presentation(None); + p.props.title = "Quarterly review".into(); + p.props.subject = "Owned corpus".into(); + p.props.author = "DeckCraft tests".into(); + let img = p.add_media("mark.png", "image/png", png(4, 4, [20, 90, 160])).expect("media"); + + { + let id = p.slides[0].id; + let s = p.slide_mut(id).expect("slide"); + s.notes = TextBody::from_text("Say the number slowly"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::new("Quarterly review"), + Run::with( + "North report", + RunProps { + link: Some(Hyperlink { + action: Action::Url { url: "https://example.org/north?q=1&x=2".into() }, + tooltip: "Open the report".into(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }, + ), + ], + ..Default::default() + }], + ..Default::default() + }); + } + s.comments = vec![Comment { + author: "Ada".into(), + initials: "AL".into(), + text: "Ada checks the axis".into(), + date: "2026-03-04T05:06:07.000".into(), + x: 40.0, + y: 40.0, + ..Default::default() + }]; + } + + let mut s = new_slide(&mut p, "Figures"); + let mut pic = shape(&mut p, "Mark", 60.0, 80.0, 80.0, 80.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } }; + pic.geom = Geom::preset("rect"); + let mut table = Table::new(2, 2, 200.0, 24.0); + for (r, c, text) in [(0, 0, "Region"), (0, 1, "Amount"), (1, 0, "North"), (1, 1, "12")] { + if let Some(cell) = table.cell_mut(r, c) { + cell.text = TextBody::from_text(text); + } + } + let mut ts = shape(&mut p, "Regions", 160.0, 80.0, 200.0, 48.0); + ts.kind = ShapeKind::Table(table); + let mut chart = Chart::sample(ChartType::Column); + chart.title = Some("Revenue".into()); + chart.categories = vec!["Jan".into(), "Feb".into()]; + chart.series.truncate(1); + if let Some(series) = chart.series.first_mut() { + series.name = "Actual".into(); + series.values = vec![Some(10.0), Some(20.0)]; + } + chart.axis_titles = (Some("Month".into()), Some("Amount due".into())); + let mut ch = shape(&mut p, "Revenue chart", 400.0, 80.0, 280.0, 180.0); + ch.kind = ShapeKind::Chart(Box::new(chart)); + s.shapes.extend([pic, ts, ch]); + p.slides.push(Arc::new(s)); + p +} + +struct Pkg { + parts: BTreeMap>, +} + +impl Pkg { + fn open(bytes: &[u8]) -> Self { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut parts = BTreeMap::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + if f.is_dir() { + continue; + } + let name = f.name().trim_start_matches('/').to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + parts.insert(name, data); + } + Pkg { parts } + } + + fn replace(&self, name: &str, data: Vec) -> Vec { + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let mut wrote = false; + for (n, bytes) in &self.parts { + let body = if n == name { + wrote = true; + data.as_slice() + } else { + bytes.as_slice() + }; + out.start_file(n, opts).expect("start"); + out.write_all(body).expect("write"); + } + assert!(wrote, "missing part {name}"); + out.finish().expect("finish").into_inner() + } +} + +fn xml_text(parts: &BTreeMap>) -> BTreeMap { + let mut counts = BTreeMap::new(); + for (name, bytes) in parts { + let lower = name.to_ascii_lowercase(); + if !(lower.ends_with(".xml") || lower.ends_with(".rels")) { + continue; + } + let Ok(xml) = std::str::from_utf8(bytes) else { continue }; + for tag in ["a:t", "c:v", "p:text", "dc:title", "dc:subject", "dc:creator", "dc:description"] { + for text in element_texts(xml, tag) { + if !text.is_empty() { + *counts.entry(text).or_insert(0) += 1; + } + } + } + for url in attr_values(xml, "Target") { + if url.starts_with("http://") || url.starts_with("https://") { + *counts.entry(url).or_insert(0) += 1; + } + } + for tip in attr_values(xml, "tooltip") { + if !tip.is_empty() { + *counts.entry(tip).or_insert(0) += 1; + } + } + } + counts +} + +fn element_texts(xml: &str, tag: &str) -> Vec { + let mut out = vec![]; + let open = format!("<{tag}"); + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let after = &rest[i + open.len()..]; + let boundary = after.chars().next(); + if !matches!(boundary, Some('>' | '/' | ' ' | '\n' | '\t' | '\r')) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { break }; + if after.as_bytes().get(gt.saturating_sub(1)) == Some(&b'/') { + rest = &after[gt + 1..]; + continue; + } + let body = &after[gt + 1..]; + let close = format!(""); + let Some(end) = body.find(&close) else { break }; + out.push(decode_xml(&body[..end])); + rest = &body[end + close.len()..]; + } + out +} + +fn attr_values(xml: &str, name: &str) -> Vec { + let mut out = vec![]; + let key = format!("{name}=\""); + let mut rest = xml; + while let Some(i) = rest.find(&key) { + let after = &rest[i + key.len()..]; + let Some(end) = after.find('"') else { break }; + out.push(decode_xml(&after[..end])); + rest = &after[end + 1..]; + } + out +} + +fn decode_xml(s: &str) -> String { + let mut out = String::new(); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let after = &rest[i..]; + let Some(end) = after.find(';') else { + out.push_str(after); + return out; + }; + let ent = &after[1..end]; + let ch = match ent { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + hex if hex.starts_with("#x") || hex.starts_with("#X") => { + u32::from_str_radix(&hex[2..], 16).ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default() + } + dec if dec.starts_with('#') => dec[1..].parse::().ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default(), + _ => String::new(), + }; + out.push_str(&ch); + rest = &after[end + 1..]; + } + out.push_str(rest); + out +} + +fn part_count(parts: &BTreeMap>, pred: impl Fn(&str) -> bool) -> usize { + parts.keys().filter(|n| pred(n)).count() +} + +fn content_counts(parts: &BTreeMap>) -> [usize; 6] { + let is = |prefix: &str, name: &str| { + let n = name.to_ascii_lowercase(); + n.starts_with(prefix) && n.ends_with(".xml") && !n.contains("_rels") + }; + [ + part_count(parts, |n| is("ppt/slides/slide", n)), + part_count(parts, |n| is("ppt/notesslides/notesslide", n)), + part_count(parts, |n| is("ppt/notesmasters/notesmaster", n)), + part_count(parts, |n| is("ppt/charts/chart", n)), + part_count(parts, |n| n.to_ascii_lowercase().starts_with("ppt/media/") && !n.to_ascii_lowercase().contains("_rels")), + part_count(parts, |n| is("ppt/comments/comment", n)), + ] +} + +fn lost<'a>(origin: &'a BTreeMap, saved: &BTreeMap) -> Vec<(&'a str, usize, usize)> { + origin + .iter() + .filter_map(|(s, n)| { + let got = saved.get(s).copied().unwrap_or(0); + (got < *n).then_some((s.as_str(), *n, got)) + }) + .collect() +} + +/// Open, save, reopen. Text from the original is still there, content parts are still there, and +/// saving again does not change the package's part names or text. +fn round_trip(label: &str, bytes: &[u8]) -> (Pkg, Pkg) { + let origin = Pkg::open(bytes); + let once = deckcraft_pptx::export(&deckcraft_pptx::import(bytes).unwrap_or_else(|e| panic!("{label}: import: {e}"))) + .unwrap_or_else(|e| panic!("{label}: save: {e}")); + let saved = Pkg::open(&once); + let origin_text = xml_text(&origin.parts); + let missing = lost(&origin_text, &xml_text(&saved.parts)); + assert!(missing.is_empty(), "{label}: text lost on save: {missing:?}"); + assert_eq!(content_counts(&origin.parts), content_counts(&saved.parts), "{label}: content parts changed"); + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&once).unwrap_or_else(|e| panic!("{label}: reopen: {e}"))) + .unwrap_or_else(|e| panic!("{label}: second save: {e}")); + let saved2 = Pkg::open(&again); + assert_eq!(saved.parts.keys().collect::>(), saved2.parts.keys().collect::>(), "{label}: part names changed on the second save"); + assert_eq!(xml_text(&saved.parts), xml_text(&saved2.parts), "{label}: text changed on the second save"); + (origin, saved) +} + +fn set_placeholder_text(xml: &str, ph: &str, text: &str) -> String { + let needle = format!("type=\"{ph}\""); + let at = xml.find(&needle).unwrap_or_else(|| panic!("notes master has no {ph} placeholder")); + let after = &xml[at..]; + let start_rel = after.find("").unwrap_or_else(|| panic!("no txBody after {ph}")); + let end_rel = after.find("").unwrap_or_else(|| panic!("no txBody end after {ph}")); + assert!(start_rel < end_rel, "{ph} txBody is out of order"); + let start = at + start_rel; + let end = at + end_rel + "".len(); + let body = format!("{text}"); + let mut out = String::with_capacity(xml.len() + body.len()); + out.push_str(&xml[..start]); + out.push_str(&body); + out.push_str(&xml[end..]); + out +} + +/// A received deck whose notes master carries the header and footer (where PowerPoint stores them). +fn notes_header_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let id = p.slides[0].id; + let s = p.slide_mut(id).expect("slide"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text("Quarterly review")); + } + s.notes = TextBody::from_text("Pause after the title"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let pkg = Pkg::open(&bytes); + let master = pkg.parts.get("ppt/notesMasters/notesMaster1.xml").expect("notes master").clone(); + let xml = String::from_utf8(master).expect("utf8"); + let xml = set_placeholder_text(&xml, "hdr", "Acme review"); + let xml = set_placeholder_text(&xml, "ftr", "Do not circulate"); + pkg.replace("ppt/notesMasters/notesMaster1.xml", xml.into_bytes()) +} + +fn placeholder_text(p: &Presentation, kind: PhType) -> String { + p.notes_master + .as_ref() + .and_then(|m| m.shapes.iter().find(|s| s.ph_type() == Some(kind))) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text()) + .unwrap_or_default() +} + +#[test] +fn owned_corpus_round_trip_keeps_parts_and_text() { + let talk_bytes = deckcraft_pptx::export(&talk()).expect("talk"); + let (origin, saved) = round_trip("talk", &talk_bytes); + let text = xml_text(&origin.parts); + for s in [ + "Quarterly review", + "North report", + "Say the number slowly", + "Region", + "North", + "Revenue", + "Jan", + "Feb", + "Actual", + "Month", + "Amount due", + "Ada checks the axis", + "Owned corpus", + "DeckCraft tests", + "https://example.org/north?q=1&x=2", + "Open the report", + ] { + assert!(text.contains_key(s), "talk corpus is missing {s:?}"); + } + let mark = png(4, 4, [20, 90, 160]); + assert!(saved.parts.values().any(|b| b == &mark), "the picture bytes did not survive the save"); + assert_eq!(content_counts(&origin.parts), [2, 1, 1, 1, 1, 1], "talk corpus shape"); + + let notes = notes_header_deck(); + let _ = round_trip("notes header", ¬es); + let opened = deckcraft_pptx::import(¬es).expect("import notes header"); + assert_eq!(placeholder_text(&opened, PhType::Header), "Acme review"); + assert_eq!(placeholder_text(&opened, PhType::Footer), "Do not circulate"); + let saved = deckcraft_pptx::export(&opened).expect("save notes header"); + let reopened = deckcraft_pptx::import(&saved).expect("reopen notes header"); + assert_eq!(placeholder_text(&reopened, PhType::Header), "Acme review"); + assert_eq!(placeholder_text(&reopened, PhType::Footer), "Do not circulate"); + assert_eq!(reopened.slides[0].notes_text(), "Pause after the title"); +} diff --git a/crates/pptx/tests/corpus_charts.rs b/crates/pptx/tests/corpus_charts.rs new file mode 100644 index 00000000..ee3faffd --- /dev/null +++ b/crates/pptx/tests/corpus_charts.rs @@ -0,0 +1,187 @@ +//! Owned corpus slice: combo charts (labels and trendlines), merged tables with diagonal +//! borders, and text columns, autofit, vertical text and a WordArt warp. +//! +//! A combo series can sit on the secondary value axis (`secondary_axis`, `y2_*`). That axis is +//! read from files; rebuilding a chart does not write it yet, so this test does not require it +//! to survive. + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use deckcraft_geom::Xfrm; +use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; +use deckcraft_model::style::{ColorRef, Line}; +use deckcraft_model::table::Table; +use deckcraft_model::text::{AutoFit, TextBody, TextDir}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults}; + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id().expect("id")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } +} + +fn round(p: &Presentation) -> Presentation { + let bytes = deckcraft_pptx::export(p).expect("export"); + deckcraft_pptx::import(&bytes).expect("import") +} + +fn by_name<'a>(s: &'a Slide, name: &str) -> &'a Shape { + s.shapes.iter().find(|x| x.name == name).unwrap_or_else(|| panic!("no shape {name}")) +} + +fn plain(t: &TextBody) -> String { + t.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()).collect::>().join("\n") +} + +fn trend(kind: TrendlineType) -> Trendline { + Trendline { kind, ..Default::default() } +} + +fn corpus() -> Presentation { + let mut p = defaults::new_presentation(None); + p.props.title = "Chart and table corpus".into(); + let mut s = { + let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::Blank).unwrap_or_default(); + defaults::new_slide(&mut p, layout).expect("slide") + }; + + let chart = Chart { + kind: ChartType::Combo, + title: Some("Spend and clicks".into()), + categories: vec!["W1".into(), "W2".into(), "W3".into(), "W4".into()], + data_labels: true, + y2_max: Some(8000.0), + y2_format: Some("0.0".into()), + series: vec![ + Series { + name: "Spend".into(), + values: vec![Some(200.0), Some(310.0), Some(280.0), Some(360.0)], + kind: Some(ChartType::Column), + trendlines: vec![ + Trendline { kind: TrendlineType::Poly, name: Some("Cubic".into()), order: Some(3), ..Default::default() }, + trend(TrendlineType::Exp), + ], + ..Default::default() + }, + Series { + name: "Clicks".into(), + values: vec![Some(6000.0), Some(7400.0), Some(7100.0), Some(7900.0)], + kind: Some(ChartType::LineMarkers), + secondary_axis: true, + data_labels: Some(false), + trendlines: vec![ + Trendline { + kind: TrendlineType::Linear, + name: Some("Fit & forecast".into()), + forward: Some(1.0), + backward: Some(0.5), + intercept: Some(0.0), + disp_eq: true, + disp_r_sqr: true, + ..Default::default() + }, + trend(TrendlineType::Log), + trend(TrendlineType::Power), + Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() }, + ], + ..Default::default() + }, + ], + ..Default::default() + }; + let mut ch = shape(&mut p, "Combo", 40.0, 40.0, 420.0, 260.0); + ch.kind = ShapeKind::Chart(Box::new(chart)); + s.shapes.push(ch); + + let mut table = Table::new(3, 3, 360.0, 28.0); + if let Some(cell) = table.cell_mut(0, 0) { + cell.text = TextBody::from_text("Merged"); + } + assert!(table.merge(0, 0, 1, 1)); + if let Some(cell) = table.cell_mut(0, 2) { + cell.text = TextBody::from_text("Diag"); + cell.diag_down = Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5)); + cell.diag_up = Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0)); + } + let mut ts = shape(&mut p, "Merged table", 40.0, 320.0, 360.0, 84.0); + ts.kind = ShapeKind::Table(table); + s.shapes.push(ts); + + let mut columns = TextBody::from_text("Two columns\nof vertical text"); + columns.body.columns = Some(3); + columns.body.col_spacing = Some(18.0); + columns.body.autofit = Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 }); + columns.body.vert = Some(TextDir::Vertical270); + let mut col = shape(&mut p, "Columns", 480.0, 40.0, 200.0, 160.0); + col.text = Some(columns); + s.shapes.push(col); + + let mut warp = TextBody::from_text("Arch"); + warp.body.warp = Some("textArchUp".into()); + warp.body.warp_adj = vec![5_000_000.0]; + warp.body.autofit = Some(AutoFit::Shape); + let mut word = shape(&mut p, "Warp", 480.0, 220.0, 200.0, 80.0); + word.text = Some(warp); + s.shapes.push(word); + + p.slides.push(Arc::new(s)); + p +} + +fn chart_of(s: &Slide) -> &Chart { + let ShapeKind::Chart(c) = &by_name(s, "Combo").kind else { panic!("not a chart") }; + c +} + +#[test] +fn chart_table_and_text_corpus_round_trips() { + let p = corpus(); + let q = round(&p); + let slide = &q.slides[1]; + let src = chart_of(&p.slides[1]); + let got = chart_of(slide); + assert_eq!(got.kind, ChartType::Combo); + assert_eq!(got.title.as_deref(), Some("Spend and clicks")); + assert_eq!(got.categories, src.categories); + assert!(got.data_labels); + assert_eq!(got.series.len(), 2); + for (a, b) in src.series.iter().zip(got.series.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.values, b.values); + assert_eq!(a.kind, b.kind); + assert_eq!(a.data_labels, b.data_labels); + assert_eq!(a.trendlines, b.trendlines); + } + + let twice = round(&q); + let again = chart_of(&twice.slides[1]); + for (a, b) in got.series.iter().zip(again.series.iter()) { + assert_eq!(a.trendlines, b.trendlines); + assert_eq!(a.data_labels, b.data_labels); + } + + let ShapeKind::Table(t) = &by_name(slide, "Merged table").kind else { panic!("not a table") }; + let anchor = t.cell(0, 0).expect("anchor"); + assert_eq!((anchor.grid_span, anchor.row_span), (2, 2)); + assert!(!anchor.h_merge && !anchor.v_merge); + assert_eq!(plain(&anchor.text), "Merged"); + assert!(t.cell(0, 1).is_some_and(|c| c.h_merge && !c.v_merge)); + assert!(t.cell(1, 0).is_some_and(|c| !c.h_merge && c.v_merge)); + assert!(t.cell(1, 1).is_some_and(|c| c.h_merge && c.v_merge)); + let diag = t.cell(0, 2).expect("diag"); + assert_eq!(diag.diag_down, Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5))); + assert_eq!(diag.diag_up, Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0))); + assert_eq!(plain(&diag.text), "Diag"); + + let cols = by_name(slide, "Columns").text.as_ref().expect("columns"); + assert_eq!(plain(cols), "Two columns\nof vertical text"); + assert_eq!(cols.body.columns, Some(3)); + assert_eq!(cols.body.col_spacing, Some(18.0)); + assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 })); + assert_eq!(cols.body.vert, Some(TextDir::Vertical270)); + + let warp = by_name(slide, "Warp").text.as_ref().expect("warp"); + assert_eq!(plain(warp), "Arch"); + assert_eq!(warp.body.warp.as_deref(), Some("textArchUp")); + assert_eq!(warp.body.warp_adj, vec![5_000_000.0]); + assert_eq!(warp.body.autofit, Some(AutoFit::Shape)); +} diff --git a/crates/pptx/tests/corpus_elements.rs b/crates/pptx/tests/corpus_elements.rs new file mode 100644 index 00000000..7d3a41da --- /dev/null +++ b/crates/pptx/tests/corpus_elements.rs @@ -0,0 +1,299 @@ +//! Owned round trip: comments, hyperlinks and actions, custom shows, grouped +//! rotated objects, and placeholders that inherit from the layout. +//! +//! Embedded fonts stay out of this check; another open change owns that part. + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use deckcraft_geom::Xfrm; +use deckcraft_model::style::{ColorRef, Fill}; +use deckcraft_model::text::{Action, Hyperlink, LevelStyle, ListStyle, Paragraph, Run, RunProps, TextBody}; +use deckcraft_model::{Comment, CustomShow, Geom, LayoutType, PhType, Presentation, Shape, ShapeId, ShapeKind, defaults, resolve}; + +const CHIME: &[u8] = b"RIFFdeckcraft-chime-WAVEfmt "; + +fn link(action: Action) -> Hyperlink { + Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } +} + +fn box_at(p: &mut Presentation, name: &str, x: f64, y: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id().expect("id")), name: name.into(), xfrm: Some(Xfrm::new(x, y, 80.0, 28.0)), ..Default::default() } +} + +fn round(p: &Presentation) -> Presentation { + deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import") +} + +fn shape<'a>(p: &'a Presentation, slide: usize, name: &str) -> &'a Shape { + p.slides[slide].shapes.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("missing {name}")) +} + +fn placeholder(p: &Presentation, kind: PhType) -> &Shape { + p.slides[0].shapes.iter().find(|s| s.ph_type() == Some(kind)).unwrap_or_else(|| panic!("missing {kind:?}")) +} + +fn deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).expect("layout"); + let master = Arc::make_mut(&mut p.masters[0]); + let lay = master.layouts.iter_mut().find(|l| l.id == layout).expect("layout"); + for s in &mut lay.shapes { + if s.ph_type() != Some(PhType::Obj) { + continue; + } + s.xfrm = Some(Xfrm::new(48.0, 120.0, 600.0, 300.0)); + s.geom = Geom::preset("ellipse"); + s.fill = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(20, 40, 80)))); + let mut ls = ListStyle::default(); + ls.set(0, LevelStyle { run: RunProps { size: Some(28.0), font: Some("Source Serif 4".into()), ..Default::default() }, ..Default::default() }); + let body = s.text.get_or_insert_with(TextBody::default); + body.list_style = ls; + } + + let chime = p.add_media("chime.wav", "audio/wav", CHIME.to_vec()).expect("media"); + let mut a = defaults::new_slide(&mut p, layout).expect("slide"); + a.name = "Review".into(); + if let Some(title) = a.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Title)) { + title.text = Some(TextBody::from_text("Review")); + } + if let Some(body) = a.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Obj)) { + body.text = Some(TextBody { + paragraphs: vec![ + Paragraph { + runs: vec![Run::new("Quoted")], + def_props: RunProps { + size: Some(22.0), + bold: Some(true), + font: Some("Literata".into()), + link: Some(Hyperlink { + action: Action::Url { url: "https://example.com/quote".into() }, + tooltip: "Source".into(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }, + ..Default::default() + }, + Paragraph { runs: vec![Run::new("Inherits")], ..Default::default() }, + ], + ..Default::default() + }); + } + let mut gem = box_at(&mut p, "Gem", 12.0, 18.0); + gem.xfrm = Some(Xfrm { x: 12.0, y: 18.0, w: 80.0, h: 40.0, rot: 20.0, flip_v: true, ..Default::default() }); + gem.text = Some(TextBody::from_text("Tilted")); + let mut cluster = box_at(&mut p, "Cluster", 80.0, 40.0); + cluster.xfrm = Some(Xfrm { x: 80.0, y: 40.0, w: 220.0, h: 140.0, rot: 25.0, flip_h: true, ..Default::default() }); + cluster.kind = ShapeKind::Group { children: vec![gem], child: Xfrm::new(10.0, 20.0, 400.0, 240.0) }; + a.shapes.push(cluster); + + let mut site = box_at(&mut p, "Site", 40.0, 460.0); + site.click = Some(Hyperlink { + action: Action::Url { url: "https://example.com/deck".into() }, + tooltip: "Example".into(), + highlight_click: true, + sound: None, + stop_sound: false, + zoom_return: None, + }); + a.shapes.push(site); + + a.shapes.push(box_at(&mut p, "Go second", 140.0, 460.0)); + + for (name, x, action) in [ + ("Next", 240.0, Action::NextSlide), + ("End", 330.0, Action::EndShow), + ("Back", 420.0, Action::LastViewed), + ("Last", 510.0, Action::LastSlide), + ("Open file", 600.0, Action::Program { path: "Talk notes.txt".into() }), + ("Play clip", 700.0, Action::PlayMedia), + ] { + let mut s = box_at(&mut p, name, x, 500.0); + s.click = Some(link(action)); + a.shapes.push(s); + } + + let mut hover = box_at(&mut p, "Hover", 40.0, 500.0); + hover.hover = Some(Hyperlink { + action: Action::PreviousSlide, + tooltip: "Back one".into(), + highlight_click: false, + sound: None, + stop_sound: true, + zoom_return: None, + }); + a.shapes.push(hover); + + let mut home = box_at(&mut p, "Home", 800.0, 460.0); + home.geom = Geom::preset("actionButtonHome"); + home.click = Some(Hyperlink { + action: Action::FirstSlide, + tooltip: String::new(), + highlight_click: true, + sound: None, + stop_sound: false, + zoom_return: None, + }); + a.shapes.push(home); + + let mut board = box_at(&mut p, "To board", 140.0, 500.0); + board.click = Some(Hyperlink { + action: Action::CustomShow { name: "Board".into(), returns: false }, + tooltip: "Open the board show".into(), + highlight_click: false, + sound: Some(chime), + stop_sound: false, + zoom_return: None, + }); + a.shapes.push(board); + + let mut short = box_at(&mut p, "To short", 240.0, 500.0); + short.click = Some(link(Action::CustomShow { name: "Short".into(), returns: true })); + a.shapes.push(short); + + a.comments = vec![Comment { + author: "Mina".into(), + initials: "ML".into(), + text: "Check the quote".into(), + date: "2026-03-01T09:30:00.000".into(), + x: 36.0, + y: 48.0, + ..Default::default() + }]; + + let mut b = defaults::new_slide(&mut p, layout).expect("slide"); + if let Some(title) = b.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Title)) { + title.text = Some(TextBody::from_text("Second")); + } + // The jump is filled in once this slide has an id. + let b_id = b.id; + if let Some(go) = a.shapes.iter_mut().find(|s| s.name == "Go second") { + go.click = Some(link(Action::Slide { slide: b_id })); + } + p.slides = vec![Arc::new(a), Arc::new(b)]; + let ids: Vec<_> = p.slides.iter().map(|s| s.id).collect(); + p.custom_shows = + vec![CustomShow { name: "Short".into(), slides: vec![ids[0]] }, CustomShow { name: "Board".into(), slides: vec![ids[1], ids[0]] }]; + p.show.custom_show = Some("Board".into()); + p +} + +fn assert_deck(q: &Presentation) { + assert_eq!(q.slides.len(), 2); + assert_eq!(placeholder(q, PhType::Title).text.as_ref().map(|t| t.text()), Some("Review".into())); + + let body = placeholder(q, PhType::Obj); + assert!(body.xfrm.is_none(), "a placeholder with no box of its own keeps inheriting the layout"); + assert!(matches!(&body.geom, Geom::Preset { name, .. } if name == "ellipse"), "layout geometry: {:?}", body.geom); + let ctx = resolve::Ctx::for_slide(q, &q.slides[0]).expect("ctx"); + let x = resolve::xfrm(&ctx, body); + assert_eq!((x.x, x.y, x.w, x.h), (48.0, 120.0, 600.0, 300.0)); + let (fill, _) = resolve::fill(&ctx, body); + assert_eq!(fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(20, 40, 80))))); + + let paras = &body.text.as_ref().expect("text").paragraphs; + assert_eq!(paras[0].runs[0].text, "Quoted"); + assert!(paras[0].runs[0].props.size.is_none(), "the run itself stays unset"); + assert_eq!(paras[0].def_props.size, Some(22.0)); + assert_eq!(paras[0].def_props.bold, Some(true)); + assert_eq!(paras[0].def_props.font.as_deref(), Some("Literata")); + match ¶s[0].def_props.link { + Some(h) => match &h.action { + Action::Url { url } => assert_eq!(url, "https://example.com/quote"), + other => panic!("paragraph link: {other:?}"), + }, + None => panic!("paragraph default run lost its link"), + } + assert_eq!(paras[0].def_props.link.as_ref().map(|h| h.tooltip.as_str()), Some("Source")); + let quoted = resolve::run(&ctx, body, ¶s[0], ¶s[0].runs[0].props); + assert_eq!(quoted.size, Some(22.0)); + assert_eq!(paras[1].runs[0].text, "Inherits"); + let inherited = resolve::run(&ctx, body, ¶s[1], ¶s[1].runs[0].props); + assert_eq!(inherited.size, Some(28.0), "the second paragraph takes the layout list style"); + assert_eq!(inherited.font.as_deref(), Some("Source Serif 4")); + + let cluster = shape(q, 0, "Cluster"); + let g = cluster.xfrm.expect("group box"); + assert_eq!((g.x, g.y, g.w, g.h, g.rot, g.flip_h), (80.0, 40.0, 220.0, 140.0, 25.0, true)); + let ShapeKind::Group { children, child } = &cluster.kind else { panic!("not a group") }; + assert_eq!((child.x, child.y, child.w, child.h), (10.0, 20.0, 400.0, 240.0)); + let gem = &children[0]; + assert_eq!(gem.text.as_ref().map(|t| t.text()), Some("Tilted".into())); + let gx = gem.xfrm.expect("gem"); + assert_eq!((gx.x, gx.y, gx.w, gx.h, gx.rot, gx.flip_v), (12.0, 18.0, 80.0, 40.0, 20.0, true)); + + let c = &q.slides[0].comments; + assert_eq!(c.len(), 1); + assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Mina", "ML", "Check the quote")); + assert_eq!(c[0].date, "2026-03-01T09:30:00.000"); + assert_eq!((c[0].x, c[0].y), (36.0, 48.0)); + + let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); + match click("Site").action { + Action::Url { url } => assert_eq!(url, "https://example.com/deck"), + other => panic!("{other:?}"), + } + assert_eq!(click("Site").tooltip, "Example"); + assert!(click("Site").highlight_click); + match click("Go second").action { + Action::Slide { slide } => assert_eq!(q.slides.iter().position(|s| s.id == slide), Some(1)), + other => panic!("{other:?}"), + } + assert!(matches!(click("Next").action, Action::NextSlide)); + assert!(matches!(click("End").action, Action::EndShow)); + assert!(matches!(click("Back").action, Action::LastViewed)); + assert!(matches!(click("Last").action, Action::LastSlide)); + match click("Open file").action { + Action::Program { path } => assert_eq!(path, "Talk notes.txt"), + other => panic!("{other:?}"), + } + assert!(matches!(click("Play clip").action, Action::PlayMedia)); + assert!(matches!(click("Home").action, Action::FirstSlide)); + assert!(click("Home").highlight_click); + assert!(matches!(shape(q, 0, "Home").geom, Geom::Preset { ref name, .. } if name == "actionButtonHome")); + let hover = shape(q, 0, "Hover").hover.clone().expect("hover"); + assert!(matches!(hover.action, Action::PreviousSlide)); + assert!(hover.stop_sound); + assert_eq!(hover.tooltip, "Back one"); + + match click("To board").action { + Action::CustomShow { name, returns } => { + assert_eq!(name, "Board"); + assert!(!returns, "Show and return was forced on"); + } + other => panic!("{other:?}"), + } + let snd = click("To board").sound.expect("action sound"); + assert_eq!(q.media(snd).map(|m| m.data.as_ref().clone()).as_deref(), Some(CHIME)); + match click("To short").action { + Action::CustomShow { name, returns } => { + assert_eq!(name, "Short"); + assert!(returns); + } + other => panic!("{other:?}"), + } + + assert_eq!(q.custom_shows.len(), 2); + assert_eq!(q.custom_shows[0].name, "Short"); + assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id]); + assert_eq!(q.custom_shows[1].name, "Board"); + assert_eq!(q.custom_shows[1].slides, vec![q.slides[1].id, q.slides[0].id]); + assert_eq!(q.show.custom_show.as_deref(), Some("Board")); + assert_eq!( + q.slides[1].shapes.iter().find(|s| s.ph_type() == Some(PhType::Title)).and_then(|s| s.text.as_ref()).map(|t| t.text()), + Some("Second".into()) + ); +} + +#[test] +fn comments_links_shows_groups_and_inherited_placeholders_round_trip() { + let p = deck(); + let once = round(&p); + assert_deck(&once); + let twice = round(&once); + assert_deck(&twice); +} diff --git a/crates/pptx/tests/corpus_kinds.rs b/crates/pptx/tests/corpus_kinds.rs new file mode 100644 index 00000000..56fc5adf --- /dev/null +++ b/crates/pptx/tests/corpus_kinds.rs @@ -0,0 +1,608 @@ +//! Owned corpus of more deck kinds: tables, charts, animations, media, sections, masters and layouts. +//! +//! Each package is built here. The check is open → save → reopen: text in the package and the +//! content parts. A second save keeps the same part names and text. + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::anim::{AnimStart, Animation, TextBuild}; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::text::{Action, Hyperlink, Run, RunProps, TextBody}; +use deckcraft_model::{Geom, LayoutId, LayoutType, MasterId, MediaClip, PhType, Presentation, ShapeKind, Slide, defaults}; + +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let img = image::RgbImage::from_pixel(w, h, image::Rgb(rgb)); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> deckcraft_model::Shape { + deckcraft_model::Shape { + id: deckcraft_model::ShapeId(p.alloc_id().expect("id")), + name: name.into(), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w, h)), + ..Default::default() + } +} + +fn slide_with(p: &mut Presentation, kind: LayoutType, title: &str) -> Slide { + let layout = defaults::layout_of_kind(p, kind).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout).expect("slide"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(title)); + } + s +} + +fn tables() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Regions"); + let mut t = Table::new(3, 3, 420.0, 28.0); + for (r, c, text) in [ + (0, 0, "Quarter"), + (0, 1, "Region"), + (0, 2, "Amount"), + (1, 0, "Q1"), + (1, 1, "North"), + (1, 2, "12"), + (2, 0, "Q2"), + (2, 1, "South"), + (2, 2, "Total"), + ] { + if let Some(cell) = t.cell_mut(r, c) { + cell.text = TextBody::from_text(text); + } + } + t.merge(2, 1, 1, 2); + t.style = "light2-accent3".into(); + let mut ts = shape(&mut p, "Region table", 80.0, 140.0, 420.0, 84.0); + ts.kind = ShapeKind::Table(t); + s.shapes.push(ts); + p.slides = vec![Arc::new(s)]; + p +} + +fn chart_of(p: &mut Presentation, name: &str, kind: ChartType, title: &str, x: f64) -> deckcraft_model::Shape { + let mut chart = Chart::sample(kind); + chart.title = Some(title.into()); + chart.categories = vec!["Jan".into(), "Feb".into()]; + chart.series.truncate(1); + if let Some(series) = chart.series.first_mut() { + series.name = "Actual".into(); + series.values = vec![Some(10.0), Some(20.0)]; + } + if !matches!(kind, ChartType::Pie) { + chart.axis_titles = (Some("Month".into()), Some("Amount due".into())); + } + let mut ch = shape(p, name, x, 120.0, 280.0, 200.0); + ch.kind = ShapeKind::Chart(Box::new(chart)); + ch +} + +fn charts() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::Blank, "Charts"); + s.shapes.extend([ + chart_of(&mut p, "Column chart", ChartType::Column, "Revenue", 40.0), + chart_of(&mut p, "Line chart", ChartType::Line, "Trend", 340.0), + chart_of(&mut p, "Pie chart", ChartType::Pie, "Share", 640.0), + ]); + p.slides = vec![Arc::new(s)]; + p +} + +fn animations() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Motion"); + let mut flyer = shape(&mut p, "Flyer", 80.0, 160.0, 200.0, 80.0); + flyer.geom = Geom::preset("roundRect"); + flyer.text = Some(TextBody::from_text("Fly in")); + let fly = flyer.id; + let mut built = shape(&mut p, "Build list", 360.0, 160.0, 240.0, 120.0); + built.text = Some(TextBody::from_text("One\nTwo\nThree")); + let body = built.id; + s.shapes.extend([flyer, built]); + s.animations = vec![ + Animation { shape: fly, effect: "fly".into(), option: "l".into(), duration_ms: 750, ..Default::default() }, + Animation { + shape: body, + effect: "fade".into(), + text_build: TextBuild::ByParagraph, + start: AnimStart::AfterPrevious, + duration_ms: 400, + ..Default::default() + }, + ]; + s.transition = Some(deckcraft_model::anim::Transition { kind: "wipe".into(), option: "l".into(), duration_ms: 800, ..Default::default() }); + p.slides = vec![Arc::new(s)]; + p +} + +fn media() -> Presentation { + let mut p = defaults::new_presentation(None); + let picture = p.add_media("mark.png", "image/png", png(4, 4, [20, 90, 160])).expect("media"); + let tone = p.add_media("tone.wav", "audio/wav", b"owned-wav-tone".to_vec()).expect("media"); + let clip = p.add_media("clip.mp4", "video/mp4", b"owned-mp4-clip".to_vec()).expect("media"); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Media"); + let mut pic = shape(&mut p, "Mark", 60.0, 120.0, 80.0, 80.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: picture, ..Default::default() } }; + pic.geom = Geom::preset("rect"); + let mut audio = shape(&mut p, "Tone", 180.0, 120.0, 80.0, 80.0); + audio.kind = ShapeKind::Media(MediaClip { media: tone, video: false, bookmarks: vec![("Cue the demo".into(), 400)], ..Default::default() }); + let mut video = shape(&mut p, "Clip", 300.0, 120.0, 160.0, 90.0); + video.kind = ShapeKind::Media(MediaClip { media: clip, video: true, poster: Some(picture), ..Default::default() }); + s.shapes.extend([pic, audio, video]); + p.slides = vec![Arc::new(s)]; + p +} + +fn sections() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut first = slide_with(&mut p, LayoutType::Title, "Opening remarks"); + if let Some(t) = first.shapes.get_mut(1) { + t.text = Some(TextBody { + paragraphs: vec![deckcraft_model::text::Paragraph { + runs: vec![Run::with( + "Brief", + RunProps { + link: Some(Hyperlink { + action: Action::Url { url: "https://example.org/brief?q=1&x=2".into() }, + tooltip: "Open the brief".into(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }, + )], + ..Default::default() + }], + ..Default::default() + }); + } + let second = slide_with(&mut p, LayoutType::SectionHeader, "Figures"); + let ids = [first.id, second.id]; + p.slides = vec![Arc::new(first), Arc::new(second)]; + p.sections = vec![ + deckcraft_model::Section { name: "Opening".into(), slides: vec![ids[0]] }, + deckcraft_model::Section { name: "Figures".into(), slides: vec![ids[1]] }, + ]; + p.custom_shows = vec![deckcraft_model::CustomShow { name: "Short show".into(), slides: vec![ids[0]] }]; + p +} + +fn masters() -> Presentation { + let mut p = defaults::new_presentation(None); + if let Some(m) = p.masters.first_mut() { + let m = Arc::make_mut(m); + if let Some(f) = m.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Footer)) { + f.text = Some(TextBody::from_text("Slide footer line")); + } + } + let mut evening = (**p.masters.first().expect("master")).clone(); + evening.id = MasterId(p.alloc_id().expect("id")); + evening.name = "Evening".into(); + evening.shapes.retain(|s| s.ph_type().is_some()); + let mut banner = shape(&mut p, "Evening banner", 40.0, 40.0, 400.0, 40.0); + banner.text = Some(TextBody::from_text("Evening master")); + evening.shapes.push(banner); + let mut quote = evening.layouts.first().expect("layout").clone(); + quote.id = LayoutId(p.alloc_id().expect("id")); + quote.name = "Quote".into(); + quote.kind = LayoutType::Custom; + quote.shapes.retain(|s| s.ph_type() == Some(PhType::CtrTitle) || s.ph_type() == Some(PhType::Title)); + let mut line = shape(&mut p, "Quote line", 80.0, 200.0, 600.0, 80.0); + line.text = Some(TextBody::from_text("Pull quote")); + quote.shapes.push(line); + evening.layouts = vec![quote.clone()]; + let quote_id = quote.id; + p.masters.push(Arc::new(evening)); + + let title = slide_with(&mut p, LayoutType::Title, "Welcome"); + let agenda = slide_with(&mut p, LayoutType::SectionHeader, "Agenda"); + let compared = slide_with(&mut p, LayoutType::Comparison, "Comparison"); + let mut quoted = defaults::new_slide(&mut p, quote_id).expect("slide"); + if let Some(t) = quoted.shapes.first_mut() { + t.text = Some(TextBody::from_text("A line")); + } + p.slides = vec![Arc::new(title), Arc::new(agenda), Arc::new(compared), Arc::new(quoted)]; + p +} + +struct Pkg { + parts: BTreeMap>, +} + +impl Pkg { + fn open(bytes: &[u8]) -> Self { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut parts = BTreeMap::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + if f.is_dir() { + continue; + } + let name = f.name().trim_start_matches('/').to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + parts.insert(name, data); + } + Pkg { parts } + } + + fn bytes(&self) -> Vec { + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (n, data) in &self.parts { + out.start_file(n, opts).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() + } + + fn text_mut(&mut self, name: &str, body: String) { + self.parts.insert(name.to_string(), body.into_bytes()); + } +} + +fn xml_text(parts: &BTreeMap>) -> BTreeMap { + let mut counts = BTreeMap::new(); + for (name, bytes) in parts { + let lower = name.to_ascii_lowercase(); + if !(lower.ends_with(".xml") || lower.ends_with(".rels")) { + continue; + } + let Ok(xml) = std::str::from_utf8(bytes) else { continue }; + for tag in ["a:t", "c:v", "p:text", "dc:title", "dc:subject", "dc:creator", "dc:description"] { + for text in element_texts(xml, tag) { + if !text.is_empty() { + *counts.entry(text).or_insert(0) += 1; + } + } + } + for tag in ["p:cSld", "p14:section", "p:custShow"] { + for text in tag_attr(xml, tag, "name") { + if !text.is_empty() { + *counts.entry(text).or_insert(0) += 1; + } + } + } + for url in attr_values(xml, "Target") { + if url.starts_with("http://") || url.starts_with("https://") { + *counts.entry(url).or_insert(0) += 1; + } + } + for tip in attr_values(xml, "tooltip") { + if !tip.is_empty() { + *counts.entry(tip).or_insert(0) += 1; + } + } + } + counts +} + +fn element_texts(xml: &str, tag: &str) -> Vec { + let mut out = vec![]; + let open = format!("<{tag}"); + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let after = &rest[i + open.len()..]; + let boundary = after.chars().next(); + if !matches!(boundary, Some('>' | '/' | ' ' | '\n' | '\t' | '\r')) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { break }; + if after.as_bytes().get(gt.saturating_sub(1)) == Some(&b'/') { + rest = &after[gt + 1..]; + continue; + } + let body = &after[gt + 1..]; + let close = format!(""); + let Some(end) = body.find(&close) else { break }; + out.push(decode_xml(&body[..end])); + rest = &body[end + close.len()..]; + } + out +} + +fn tag_attr(xml: &str, tag: &str, attr: &str) -> Vec { + let mut out = vec![]; + let open = format!("<{tag}"); + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let after = &rest[i..]; + let Some(gt) = after.find('>') else { break }; + let start = after[..gt].find(&format!("{attr}=\"")).map(|k| k + attr.len() + 2); + if let Some(start) = start + && let Some(end) = after[start..gt].find('"') + { + out.push(decode_xml(&after[start..start + end])); + } + rest = &after[gt + 1..]; + } + out +} + +fn attr_values(xml: &str, name: &str) -> Vec { + let mut out = vec![]; + let key = format!("{name}=\""); + let mut rest = xml; + while let Some(i) = rest.find(&key) { + let after = &rest[i + key.len()..]; + let Some(end) = after.find('"') else { break }; + out.push(decode_xml(&after[..end])); + rest = &after[end + 1..]; + } + out +} + +fn decode_xml(s: &str) -> String { + let mut out = String::new(); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let after = &rest[i..]; + let Some(end) = after.find(';') else { + out.push_str(after); + return out; + }; + let ent = &after[1..end]; + let ch = match ent { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + hex if hex.starts_with("#x") || hex.starts_with("#X") => { + u32::from_str_radix(&hex[2..], 16).ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default() + } + dec if dec.starts_with('#') => dec[1..].parse::().ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default(), + _ => String::new(), + }; + out.push_str(&ch); + rest = &after[end + 1..]; + } + out.push_str(rest); + out +} + +fn part_count(parts: &BTreeMap>, pred: impl Fn(&str) -> bool) -> usize { + parts.keys().filter(|n| pred(n)).count() +} + +/// Slides, charts, media, comments, handout masters. +fn content_counts(parts: &BTreeMap>) -> [usize; 5] { + let is = |prefix: &str, name: &str| { + let n = name.to_ascii_lowercase(); + n.starts_with(prefix) && n.ends_with(".xml") && !n.contains("_rels") + }; + [ + part_count(parts, |n| is("ppt/slides/slide", n)), + part_count(parts, |n| is("ppt/charts/chart", n)), + part_count(parts, |n| n.to_ascii_lowercase().starts_with("ppt/media/") && !n.to_ascii_lowercase().contains("_rels")), + part_count(parts, |n| is("ppt/comments/comment", n)), + part_count(parts, |n| is("ppt/handoutmasters/handoutmaster", n)), + ] +} + +fn lost<'a>(origin: &'a BTreeMap, saved: &BTreeMap) -> Vec<(&'a str, usize, usize)> { + origin + .iter() + .filter_map(|(s, n)| { + let got = saved.get(s).copied().unwrap_or(0); + (got < *n).then_some((s.as_str(), *n, got)) + }) + .collect() +} + +fn round_trip(label: &str, bytes: &[u8]) -> (Pkg, Pkg) { + let origin = Pkg::open(bytes); + let once = deckcraft_pptx::export(&deckcraft_pptx::import(bytes).unwrap_or_else(|e| panic!("{label}: import: {e}"))) + .unwrap_or_else(|e| panic!("{label}: save: {e}")); + let saved = Pkg::open(&once); + let origin_text = xml_text(&origin.parts); + let missing = lost(&origin_text, &xml_text(&saved.parts)); + assert!(missing.is_empty(), "{label}: text lost on save: {missing:?}"); + assert_eq!(content_counts(&origin.parts), content_counts(&saved.parts), "{label}: content parts changed"); + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&once).unwrap_or_else(|e| panic!("{label}: reopen: {e}"))) + .unwrap_or_else(|e| panic!("{label}: second save: {e}")); + let saved2 = Pkg::open(&again); + assert_eq!(saved.parts.keys().collect::>(), saved2.parts.keys().collect::>(), "{label}: part names changed on the second save"); + assert_eq!(xml_text(&saved.parts), xml_text(&saved2.parts), "{label}: text changed on the second save"); + (origin, saved) +} + +fn require(text: &BTreeMap, label: &str, words: &[&str]) { + for s in words { + assert!(text.contains_key(*s), "{label} corpus is missing {s:?}"); + } +} + +fn ph(id: u32, kind: &str, name: &str, text: &str) -> String { + format!( + "\ + \ + {text}" + ) +} + +fn next_rid(rels: &str) -> String { + let mut max = 0u32; + let mut rest = rels; + while let Some(i) = rest.find("rId") { + let digits: String = rest[i + 3..].chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = digits.parse::() { + max = max.max(n); + } + rest = &rest[i + 3..]; + } + format!("rId{}", max + 1) +} + +/// A deck whose handout master carries the header, footer, date and slide number. +fn handout_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let s = slide_with(&mut p, LayoutType::TitleOnly, "Leave-behind"); + p.slides = vec![Arc::new(s)]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut pkg = Pkg::open(&bytes); + let master = format!( + "\ + \ + \ + \ + {}{}{}{}\ + \ + \ + ", + ph(2, "hdr", "Header Placeholder 1", "Board pack"), + ph(3, "ftr", "Footer Placeholder 2", "Not for circulation"), + ph(4, "dt", "Date Placeholder 3", "1 March 2026"), + ph(5, "sldNum", "Slide Number Placeholder 4", "3"), + ); + pkg.parts.insert("ppt/handoutMasters/handoutMaster1.xml".into(), master.into_bytes()); + pkg.parts.insert( + "ppt/handoutMasters/_rels/handoutMaster1.xml.rels".into(), + b"\ + \ + \ + " + .to_vec(), + ); + let rels_name = "ppt/_rels/presentation.xml.rels"; + let rels = String::from_utf8(pkg.parts.get(rels_name).expect("rels").clone()).expect("utf8"); + let rid = next_rid(&rels); + let rels = rels.replacen( + "", + &format!( + "" + ), + 1, + ); + pkg.text_mut(rels_name, rels); + let pres_name = "ppt/presentation.xml"; + let pres = String::from_utf8(pkg.parts.get(pres_name).expect("presentation").clone()).expect("utf8"); + let list = format!(""); + let pres = if let Some(at) = pres.find("", + "", + 1, + ); + pkg.text_mut("[Content_Types].xml", ct); + pkg.bytes() +} + +fn placeholder_text(p: &Presentation, kind: PhType) -> String { + p.handout_master + .as_ref() + .and_then(|m| m.shapes.iter().find(|s| s.ph_type() == Some(kind))) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text()) + .unwrap_or_default() +} + +#[test] +fn authored_kinds_round_trip_parts_and_text() { + let tables_bytes = deckcraft_pptx::export(&tables()).expect("tables"); + let (origin, _) = round_trip("tables", &tables_bytes); + require(&xml_text(&origin.parts), "tables", &["Quarter", "North", "12", "Total", "Regions"]); + assert_eq!(content_counts(&origin.parts), [1, 0, 0, 0, 0]); + + let charts_bytes = deckcraft_pptx::export(&charts()).expect("charts"); + let (origin, _) = round_trip("charts", &charts_bytes); + require(&xml_text(&origin.parts), "charts", &["Revenue", "Trend", "Share", "Actual", "Jan", "Feb", "Month", "Amount due"]); + assert_eq!(content_counts(&origin.parts), [1, 3, 0, 0, 0]); + + let anim_bytes = deckcraft_pptx::export(&animations()).expect("animations"); + let (origin, saved) = round_trip("animations", &anim_bytes); + require(&xml_text(&origin.parts), "animations", &["Motion", "Fly in", "One", "Two", "Three"]); + let anim = deckcraft_pptx::import(&saved.bytes()).expect("animations import"); + assert_eq!(anim.slides[0].animations.len(), 2); + assert_eq!(anim.slides[0].animations[0].effect, "fly"); + assert_eq!(anim.slides[0].animations[1].text_build, TextBuild::ByParagraph); + assert_eq!(anim.slides[0].transition.as_ref().map(|t| t.kind.as_str()), Some("wipe")); + + let media_bytes = deckcraft_pptx::export(&media()).expect("media"); + let (origin, saved) = round_trip("media", &media_bytes); + require(&xml_text(&origin.parts), "media", &["Media"]); + let mark = png(4, 4, [20, 90, 160]); + assert!(saved.parts.values().any(|b| b == &mark), "the picture bytes did not survive the save"); + assert!(saved.parts.values().any(|b| b == b"owned-wav-tone"), "the audio bytes did not survive the save"); + assert!(saved.parts.values().any(|b| b == b"owned-mp4-clip"), "the video bytes did not survive the save"); + assert!(content_counts(&origin.parts)[2] >= 3, "picture, audio and video"); + let media = deckcraft_pptx::import(&saved.bytes()).expect("media import"); + let tone = media.slides[0].shapes.iter().find(|s| s.name == "Tone").expect("tone"); + let ShapeKind::Media(clip) = &tone.kind else { panic!("not media") }; + assert_eq!(clip.bookmarks, vec![("Cue the demo".into(), 400)]); + assert!(!clip.video); + + let section_bytes = deckcraft_pptx::export(§ions()).expect("sections"); + let (origin, saved) = round_trip("sections", §ion_bytes); + require( + &xml_text(&origin.parts), + "sections", + &["Opening remarks", "Brief", "Figures", "Opening", "Short show", "https://example.org/brief?q=1&x=2", "Open the brief"], + ); + let sections = deckcraft_pptx::import(&saved.bytes()).expect("sections import"); + assert_eq!(sections.sections.iter().map(|s| s.name.as_str()).collect::>(), vec!["Opening", "Figures"]); + assert_eq!(sections.custom_shows[0].name, "Short show"); + + let master_bytes = deckcraft_pptx::export(&masters()).expect("masters"); + let (origin, saved) = round_trip("masters", &master_bytes); + require( + &xml_text(&origin.parts), + "masters", + &[ + "Welcome", + "Agenda", + "Comparison", + "Pull quote", + "Evening", + "Evening master", + "Quote", + "Slide footer line", + "Title Slide", + "Section Header", + ], + ); + let masters = deckcraft_pptx::import(&saved.bytes()).expect("masters import"); + assert_eq!(masters.masters.len(), 2); + assert!(masters.masters.iter().any(|m| m.name == "Evening")); + assert!(masters.masters.iter().flat_map(|m| &m.layouts).any(|l| l.name == "Quote" && l.kind == LayoutType::Custom)); + + let handout = handout_deck(); + let (origin, _) = round_trip("handout master", &handout); + require(&xml_text(&origin.parts), "handout", &["Leave-behind", "Board pack", "Not for circulation", "1 March 2026", "Handouts"]); + assert_eq!(content_counts(&origin.parts)[4], 1, "handout master part"); + let opened = deckcraft_pptx::import(&handout).expect("import handout"); + assert_eq!(opened.handout_master.as_ref().map(|m| m.name.as_str()), Some("Handouts")); + assert_eq!(placeholder_text(&opened, PhType::Header), "Board pack"); + assert_eq!(placeholder_text(&opened, PhType::Footer), "Not for circulation"); + assert_eq!(placeholder_text(&opened, PhType::Date), "1 March 2026"); + assert_eq!(placeholder_text(&opened, PhType::SlideNum), "3"); + let saved = deckcraft_pptx::export(&opened).expect("save handout"); + let reopened = deckcraft_pptx::import(&saved).expect("reopen handout"); + assert_eq!(placeholder_text(&reopened, PhType::Header), "Board pack"); + assert_eq!(placeholder_text(&reopened, PhType::Footer), "Not for circulation"); + assert_eq!(placeholder_text(&reopened, PhType::Date), "1 March 2026"); + assert_eq!(placeholder_text(&reopened, PhType::SlideNum), "3"); + assert_eq!(reopened.slides[0].shapes.first().and_then(|s| s.text.as_ref()).map(|t| t.text()).unwrap_or_default(), "Leave-behind"); +} diff --git a/crates/pptx/tests/corpus_media.rs b/crates/pptx/tests/corpus_media.rs new file mode 100644 index 00000000..6ffd14aa --- /dev/null +++ b/crates/pptx/tests/corpus_media.rs @@ -0,0 +1,373 @@ +//! Owned corpus: SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a +//! hidden slide, slide-number and date fields, ink, 3-D models, and animation repeat, bounce and triggers. +//! +//! Relationship-bearing SmartArt and OLE parts stay with issue #203. Media playback options stay +//! with #74. Animation rewind (`cTn fill="remove"`) stays with #77. This file does not assert those. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_color::Rgba; +use deckcraft_geom::Xfrm; +use deckcraft_model::anim::{AnimClass, Animation}; +use deckcraft_model::text::{Run, RunKind, RunProps, TextBody}; +use deckcraft_model::{Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId, defaults}; + +fn png(rgb: [u8; 3]) -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn glb(marker: u8) -> Vec { + let mut v = b"glTF\x02\x00\x00\x00\x0d\x00\x00\x00".to_vec(); + v.push(marker); + v +} + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id().expect("id")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } +} + +fn new_slide(p: &mut Presentation, title: &str) -> Slide { + let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout).expect("slide"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(title)); + } + s +} + +fn field(text: &str, kind: &str) -> TextBody { + let mut body = TextBody::from_text(""); + body.paragraphs[0].runs = vec![Run { text: text.into(), props: RunProps::default(), kind: RunKind::Field { field: kind.into() } }]; + body +} + +const SMARTART: &str = r#"Quarter"#; + +const OLE: &str = r#""#; + +fn corpus() -> (Presentation, Vec, Vec, Vec) { + let mut p = defaults::new_presentation(None); + let audio_bytes = b"audio-bytes-deckcraft".to_vec(); + let video_bytes = b"video-bytes-deckcraft".to_vec(); + let block = glb(1); + let globe = glb(2); + let poster = png([20, 80, 160]); + let audio = p.add_media("clip.m4a", "audio/mp4", audio_bytes).expect("media"); + let video = p.add_media("clip.mp4", "video/mp4", video_bytes).expect("media"); + let block_id = p.add_media("block.glb", "model/gltf-binary", block.clone()).expect("media"); + let globe_id = p.add_media("globe.glb", "model/gltf-binary", globe.clone()).expect("media"); + let poster_id = p.add_media("globe-poster.png", "image/png", poster.clone()).expect("media"); + + let mut objects = new_slide(&mut p, "Objects"); + let mut diagram = shape(&mut p, "Diagram", 40.0, 80.0, 288.0, 144.0); + diagram.kind = ShapeKind::Opaque { xml: SMARTART.into(), preview: None, label: "SmartArt".into(), parts: vec![] }; + let mut embedded = shape(&mut p, "Embedded", 340.0, 80.0, 144.0, 72.0); + embedded.kind = ShapeKind::Opaque { xml: OLE.into(), preview: None, label: "DeckCraft.Note.1".into(), parts: vec![] }; + let mut block_shape = shape(&mut p, "Block", 40.0, 240.0, 120.0, 90.0); + block_shape.kind = ShapeKind::Model3d { media: block_id, poster: None }; + let mut globe_shape = shape(&mut p, "Globe", 180.0, 240.0, 160.0, 120.0); + globe_shape.kind = ShapeKind::Model3d { media: globe_id, poster: Some(poster_id) }; + let mut number = shape(&mut p, "Slide number", 360.0, 240.0, 80.0, 28.0); + number.text = Some(field("4", "slidenum")); + let mut date = shape(&mut p, "Date", 360.0, 276.0, 160.0, 28.0); + date.text = Some(field("14 October 2026", "datetime4")); + objects.shapes.extend([diagram, embedded, block_shape, globe_shape, number, date]); + p.slides.push(Arc::new(objects)); + + let mut media = new_slide(&mut p, "Media"); + let mut clip = shape(&mut p, "Narration", 40.0, 80.0, 80.0, 80.0); + clip.kind = ShapeKind::Media(MediaClip { + media: audio, + video: false, + trim_start_ms: 400, + trim_end_ms: 2600, + fade_in_ms: 80, + fade_out_ms: 120, + bookmarks: vec![("Intro".into(), 400), ("Outro".into(), 2200)], + volume: 1.0, + ..Default::default() + }); + let mut film = shape(&mut p, "Film", 160.0, 80.0, 240.0, 135.0); + film.kind = ShapeKind::Media(MediaClip { + media: video, + video: true, + poster: Some(poster_id), + trim_start_ms: 250, + trim_end_ms: 9000, + bookmarks: vec![("Mark".into(), 1500)], + volume: 1.0, + ..Default::default() + }); + let mut trigger = shape(&mut p, "Trigger", 40.0, 240.0, 100.0, 36.0); + trigger.text = Some(TextBody::from_text("Play")); + let mut fader = shape(&mut p, "Fader", 160.0, 240.0, 140.0, 60.0); + fader.text = Some(TextBody::from_text("Hello")); + let trigger_id = trigger.id; + let fader_id = fader.id; + media.shapes.extend([clip, film, trigger, fader]); + media.animations = vec![ + Animation { + shape: fader_id, + effect: "fade".into(), + duration_ms: 600, + repeat: 4, + bounce_end: 0.25, + trigger: Some(trigger_id), + ..Default::default() + }, + Animation { shape: fader_id, class: AnimClass::Emphasis, effect: "pulse".into(), duration_ms: 400, repeat: u32::MAX, ..Default::default() }, + ]; + p.slides.push(Arc::new(media)); + + let mut aside = new_slide(&mut p, "Aside"); + aside.hidden = true; + let mut ink = shape(&mut p, "Sketch", 48.0, 96.0, 200.0, 80.0); + ink.kind = ShapeKind::Ink { + strokes: vec![ + InkStroke { + points: vec![(48.0, 100.0, 0.4), (90.0, 140.0, 0.8), (150.0, 110.0, 0.3)], + color: Rgba::rgb(20, 20, 20), + width: 2.0, + highlighter: false, + }, + InkStroke { points: vec![(50.0, 150.0, 1.0), (160.0, 156.0, 1.0)], color: Rgba::rgb(255, 230, 40), width: 12.0, highlighter: true }, + ], + }; + aside.shapes.push(ink); + p.slides.push(Arc::new(aside)); + + let ids: Vec = p.slides.iter().map(|s| s.id).collect(); + let hidden = *ids.last().expect("aside"); + p.sections = vec![ + Section { name: "Open".into(), slides: ids.iter().copied().filter(|id| *id != hidden).collect() }, + Section { name: "Aside".into(), slides: vec![hidden] }, + ]; + p.first_slide_number = 4; + (p, block, globe, poster) +} + +fn round(p: &Presentation) -> Presentation { + let bytes = deckcraft_pptx::export(p).expect("export"); + deckcraft_pptx::import(&bytes).expect("import") +} + +fn by_name<'a>(p: &'a Presentation, name: &str) -> &'a Shape { + let mut found = None; + for s in &p.slides { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + if found.is_none() && x.name == name { + found = Some(x); + } + }); + } + found.unwrap_or_else(|| panic!("no shape {name}")) +} + +fn media_bytes(p: &Presentation, shape: &str) -> Vec { + let id = match &by_name(p, shape).kind { + ShapeKind::Model3d { media, .. } | ShapeKind::Media(MediaClip { media, .. }) => *media, + other => panic!("{shape} has no media: {other:?}"), + }; + p.media(id).expect("media").data.as_ref().clone() +} + +fn assert_field(p: &Presentation, name: &str, kind: &str, text: &str) { + let run = &by_name(p, name).text.as_ref().expect("text").paragraphs[0].runs[0]; + assert!(matches!(&run.kind, RunKind::Field { field } if field == kind), "{name}: {:?}", run.kind); + assert_eq!(run.text, text); +} + +fn assert_ink(s: &Shape) { + let ShapeKind::Group { children, .. } = &s.kind else { panic!("ink comes back as a group, got {:?}", s.kind) }; + assert_eq!(children.len(), 2, "pen and highlighter"); + assert!(children.iter().all(|c| matches!(c.geom, Geom::Custom { .. }))); +} + +#[test] +fn object_corpus_round_trips() { + let (p, block, globe, poster) = corpus(); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let types = zip_text(&bytes, "[Content_Types].xml"); + assert!(types.contains("model/gltf-binary"), "{types}"); + assert!(zip_has_payload(&bytes, &block), "block model was not written"); + assert!(zip_has_payload(&bytes, &globe), "globe model was not written"); + let once = deckcraft_pptx::import(&bytes).expect("import"); + let twice = round(&once); + + for q in [&once, &twice] { + let diagram = by_name(q, "Diagram"); + let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; + assert_eq!(label, "SmartArt"); + assert!(xml.contains("Quarter"), "{xml}"); + assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); + + let embedded = by_name(q, "Embedded"); + let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; + assert_eq!(label, "DeckCraft.Note.1"); + assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); + assert!(!xml.contains("r:id"), "an embedding part is issue #203"); + + let block_shape = by_name(q, "Block"); + assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); + assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); + assert_eq!(media_bytes(q, "Block"), block); + let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; + let block_media = q.media(*media).expect("block media"); + assert_eq!(block_media.content_type, "model/gltf-binary"); + assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); + + let globe_shape = by_name(q, "Globe"); + let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; + assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); + assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice()); + assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice()); + + let narration = by_name(q, "Narration"); + let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; + assert!(!m.video); + assert_eq!(m.trim_start_ms, 400); + assert_eq!(m.trim_end_ms, 2600); + assert_eq!(m.fade_in_ms, 80); + assert_eq!(m.fade_out_ms, 120); + assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); + assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); + + let film = by_name(q, "Film"); + let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; + assert!(m.video); + assert_eq!(m.trim_start_ms, 250); + assert_eq!(m.trim_end_ms, 9000); + assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); + assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); + + assert_field(q, "Slide number", "slidenum", "4"); + assert_field(q, "Date", "datetime4", "14 October 2026"); + assert_eq!(q.first_slide_number, 4); + + assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); + let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); + assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); + assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); + assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); + + assert_ink(by_name(q, "Sketch")); + + let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); + let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); + let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); + assert_eq!(fade.repeat, 4); + assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); + assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); + assert_eq!(fade.shape, by_name(q, "Fader").id); + assert_eq!(pulse.repeat, u32::MAX); + assert_eq!(pulse.class, AnimClass::Emphasis); + assert!(pulse.trigger.is_none()); + } +} + +/// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part. +fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { + let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + if parts.iter().any(|(n, _)| *n == name) { + continue; + } + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if name.ends_with(".xml") || name.ends_with(".rels") { + d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in parts { + out.start_file(*name, o).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn zip_text(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut f = z.by_name(name).unwrap_or_else(|_| panic!("no {name}")); + let mut d = String::new(); + f.read_to_string(&mut d).expect("read"); + d +} + +fn zip_has_payload(bytes: &[u8], payload: &[u8]) -> bool { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if d == payload { + return true; + } + } + false +} + +#[test] +fn an_alternate_content_model_keeps_the_glb_and_its_poster() { + let model = glb(7); + let poster = png([200, 30, 40]); + let frame = r#""#; + let bytes = with_parts(&[("ppt/media/model.glb", model.clone()), ("ppt/media/poster.png", poster.clone())], |name, s| { + if name == "ppt/slides/slide1.xml" { + s.replacen("", &format!("{frame}"), 1) + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + s.replacen( + "", + r#""#, + 1, + ) + } else { + s + } + }); + let once = deckcraft_pptx::import(&bytes).expect("import"); + let twice = round(&once); + for q in [&once, &twice] { + assert!(q.slides[0].shapes.iter().all(|s| s.name != "Model poster"), "the fallback picture replaced the model"); + let s = by_name(q, "Model from file"); + let ShapeKind::Model3d { media, poster: Some(poster_id) } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(s.xfrm, Some(Xfrm::new(40.0, 50.0, 120.0, 90.0))); + let model_item = q.media(*media).expect("glb"); + assert_eq!(model_item.data.as_ref(), model.as_slice()); + assert_eq!(model_item.content_type, "model/gltf-binary"); + assert_eq!(q.media(*poster_id).expect("poster").data.as_ref(), poster.as_slice()); + } +} + +#[test] +fn an_ink_content_part_still_uses_its_fallback_picture() { + let poster = png([10, 10, 10]); + let frame = r#""#; + let bytes = with_parts(&[("ppt/ink/ink1.xml", b"".to_vec()), ("ppt/media/ink-poster.png", poster.clone())], |name, s| { + if name == "ppt/slides/slide1.xml" { + s.replacen("", &format!("{frame}"), 1) + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + s.replacen( + "", + r#""#, + 1, + ) + } else { + s + } + }); + let p = deckcraft_pptx::import(&bytes).expect("import"); + assert!(p.slides[0].shapes.iter().all(|s| s.name != "Ink marks")); + let s = by_name(&p, "Ink poster"); + let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(p.media(fill.media).expect("poster").data.as_ref(), poster.as_slice()); +} diff --git a/crates/pptx/tests/corpus_style.rs b/crates/pptx/tests/corpus_style.rs new file mode 100644 index 00000000..65c3631c --- /dev/null +++ b/crates/pptx/tests/corpus_style.rs @@ -0,0 +1,250 @@ +//! Owned corpus: text effects, body layout, theme effects, fills, bevels and connectors. +//! Open → save → reopen, twice, and compare the model. + +use std::sync::Arc; + +use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; +use deckcraft_geom::Xfrm; +use deckcraft_model::style::{ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, PatternFill, Reflection, Shadow}; +use deckcraft_model::text::{Align, AutoFit, BodyProps, ParaProps, Paragraph, Run, RunProps, Spacing, TabStop, TextBody, TextDir}; +use deckcraft_model::{Geom, Presentation, Shape, ShapeId, ShapeKind, defaults}; + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id().expect("id")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } +} + +fn shadow(inner: bool, dir: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(if inner { Rgba::rgb(0, 0, 80) } else { Rgba::BLACK }).with(ColorTransform::Alpha(50_000)), + blur: if inner { 4.0 } else { 8.0 }, + dist: if inner { 2.0 } else { 5.0 }, + dir, + inner, + sx: if inner { 1.0 } else { 0.5 }, + sy: 1.0, + kx: 0.0, + ky: if inner { 0.0 } else { 15.0 }, + align: if inner { String::new() } else { "ctr".into() }, + rotate_with_shape: inner, + } +} + +fn glow() -> Glow { + Glow { color: ColorRef::scheme(SchemeSlot::Accent3), radius: 10.0 } +} + +fn reflection() -> Reflection { + Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0 } +} + +fn gradient(angle: f64) -> Fill { + Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: ColorRef::rgb(Rgba::rgb(255, 80, 0)) }, + GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent1) }, + ], + shape: GradientShape::Linear { angle, scaled: true }, + rotate_with_shape: true, + tile: [0.0; 4], + }) +} + +fn styled_run() -> RunProps { + RunProps { + fill: Some(gradient(20.0)), + outline: Some(Line { dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 40, 80)), 1.5) }), + shadow: Some(shadow(false, 135.0)), + inner_shadow: Some(shadow(true, 45.0)), + glow: Some(glow()), + reflection: Some(reflection()), + soft_edge: Some(4.0), + ..Default::default() + } +} + +fn style_deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let master = Arc::make_mut(p.masters.first_mut().expect("master")); + master.theme.format.effects[0] = Effects { + glow: Some(glow()), + reflection: Some(reflection()), + // Preset only: no kept `scene3d`/`sp3d`. A save must still write the bevel. + bevel: Some("circle".into()), + ..Default::default() + }; + + let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::Blank).unwrap_or_default(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + + let mut word = shape(&mut p, "Styled text", 40.0, 40.0, 400.0, 180.0); + word.text = Some(TextBody { + body: BodyProps { + autofit: Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }), + columns: Some(2), + col_spacing: Some(12.0), + vert: Some(TextDir::Vertical270), + rtl_col: Some(true), + ..Default::default() + }, + paragraphs: vec![Paragraph { + props: ParaProps { + align: Some(Align::Right), + rtl: Some(true), + line_spacing: Some(Spacing::Pct(1.5)), + space_before: Some(Spacing::Pts(6.0)), + tabs: Some(vec![ + TabStop { pos: 36.0, align: "l".into() }, + TabStop { pos: 72.0, align: "ctr".into() }, + TabStop { pos: 108.0, align: "r".into() }, + TabStop { pos: 144.0, align: "dec".into() }, + ]), + ..Default::default() + }, + runs: vec![Run::with("Gradient glow", styled_run())], + ..Default::default() + }], + ..Default::default() + }); + + let mut path = shape(&mut p, "Path gradient", 40.0, 260.0, 140.0, 100.0); + path.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: ColorRef::rgb(Rgba::rgb(10, 20, 30)) }, + GradientStop { pos: 0.5, color: ColorRef::scheme(SchemeSlot::Accent5) }, + GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(240, 240, 240)) }, + ], + shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] }, + rotate_with_shape: false, + tile: [0.0; 4], + })); + + let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0); + patt.fill = + Some(Fill::Pattern(PatternFill { preset: "smCheck".into(), fg: ColorRef::scheme(SchemeSlot::Accent4), bg: ColorRef::rgb(Rgba::WHITE) })); + + let mut named = shape(&mut p, "Bevel preset", 460.0, 260.0, 80.0, 60.0); + named.effects = Some(Effects { bevel: Some("relaxedInset".into()), ..Default::default() }); + + let mut bevel = shape(&mut p, "Bevel", 340.0, 260.0, 100.0, 80.0); + bevel.effects = Some(Effects { + raw3d: Some( + "" + .into(), + ), + ..Default::default() + }); + + let left = shape(&mut p, "Left", 40.0, 400.0, 80.0, 40.0); + let right = shape(&mut p, "Right", 240.0, 400.0, 80.0, 40.0); + let (left_id, right_id) = (left.id, right.id); + let mut cx = shape(&mut p, "Connector", 120.0, 420.0, 120.0, 0.0); + cx.kind = ShapeKind::Connector { start: Some((left_id, 3)), end: Some((right_id, 1)) }; + cx.geom = Geom::preset("straightConnector1"); + cx.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 1.5)); + + s.shapes.extend([word, path, patt, named, bevel, left, right, cx]); + p.slides.push(Arc::new(s)); + p +} + +fn round(p: &Presentation) -> Presentation { + let bytes = deckcraft_pptx::export(p).expect("export"); + deckcraft_pptx::import(&bytes).expect("import") +} + +fn by_name<'a>(p: &'a Presentation, name: &str) -> &'a Shape { + p.slides.iter().flat_map(|s| &s.shapes).find(|s| s.name == name).unwrap_or_else(|| panic!("missing {name}")) +} + +fn run_of(p: &Presentation) -> &RunProps { + &by_name(p, "Styled text").text.as_ref().expect("text").paragraphs[0].runs[0].props +} + +#[test] +fn style_kinds_round_trip() { + let p = style_deck(); + let once = round(&p); + let twice = round(&once); + + let body = &by_name(&once, "Styled text").text.as_ref().expect("text").body; + assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 })); + assert_eq!(body.columns, Some(2)); + assert_eq!(body.col_spacing, Some(12.0)); + assert_eq!(body.vert, Some(TextDir::Vertical270)); + assert_eq!(body.rtl_col, Some(true)); + let para = &by_name(&once, "Styled text").text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(para.rtl, Some(true)); + assert_eq!(para.align, Some(Align::Right)); + assert_eq!(para.line_spacing, Some(Spacing::Pct(1.5))); + assert_eq!(para.space_before, Some(Spacing::Pts(6.0))); + assert_eq!( + para.tabs.as_deref(), + Some( + [ + TabStop { pos: 36.0, align: "l".into() }, + TabStop { pos: 72.0, align: "ctr".into() }, + TabStop { pos: 108.0, align: "r".into() }, + TabStop { pos: 144.0, align: "dec".into() }, + ] + .as_slice() + ) + ); + + let got = run_of(&once); + let expect = run_of(&p); + assert_eq!(got.fill, expect.fill, "text gradient"); + assert_eq!(got.outline, expect.outline, "text outline"); + assert_eq!(got.shadow, expect.shadow, "text shadow"); + assert_eq!(got.glow, expect.glow, "text glow"); + assert_eq!(got.inner_shadow, expect.inner_shadow, "text inner shadow"); + assert_eq!(got.reflection, expect.reflection, "text reflection"); + assert_eq!(got.soft_edge, expect.soft_edge, "text soft edge"); + assert_eq!(run_of(&twice), run_of(&once)); + assert_eq!( + by_name(&twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), + by_name(&once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) + ); + + assert_eq!(by_name(&once, "Path gradient").fill, by_name(&p, "Path gradient").fill); + assert_eq!(by_name(&once, "Pattern").fill, by_name(&p, "Pattern").fill); + assert_eq!(by_name(&twice, "Path gradient").fill, by_name(&once, "Path gradient").fill); + assert_eq!(by_name(&twice, "Pattern").fill, by_name(&once, "Pattern").fill); + + let theme = &once.masters[0].theme.format.effects[0]; + assert_eq!(theme.glow, Some(glow())); + assert_eq!(theme.reflection, Some(reflection())); + assert_eq!(theme.bevel.as_deref(), Some("circle"), "theme bevel preset"); + let theme2 = &twice.masters[0].theme.format.effects[0]; + assert_eq!(theme2.glow, theme.glow); + assert_eq!(theme2.reflection, theme.reflection); + assert_eq!(theme2.bevel, theme.bevel); + assert_eq!(theme2.raw3d, theme.raw3d); + + let named = by_name(&once, "Bevel preset").effects.as_ref().expect("effects"); + assert_eq!(named.bevel.as_deref(), Some("relaxedInset")); + assert!(named.raw3d.as_deref().unwrap_or("").contains("relaxedInset")); + assert_eq!(by_name(&twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); + + let bevel = by_name(&once, "Bevel").effects.as_ref().expect("effects"); + assert_eq!(bevel.bevel.as_deref(), Some("angle")); + let raw = bevel.raw3d.as_deref().unwrap_or(""); + assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); + assert_eq!(by_name(&twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); + + let c = by_name(&once, "Connector"); + assert!( + matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(&p) && e == right_of(&p)), + "{:?}", + c.kind + ); + assert_eq!(c.line, by_name(&p, "Connector").line); + assert_eq!(c.geom, by_name(&p, "Connector").geom); + assert_eq!(by_name(&twice, "Connector").kind, c.kind); +} + +fn left_of(p: &Presentation) -> ShapeId { + by_name(p, "Left").id +} +fn right_of(p: &Presentation) -> ShapeId { + by_name(p, "Right").id +} diff --git a/crates/pptx/tests/corpus_themes.rs b/crates/pptx/tests/corpus_themes.rs new file mode 100644 index 00000000..15f76ae0 --- /dev/null +++ b/crates/pptx/tests/corpus_themes.rs @@ -0,0 +1,248 @@ +//! Owned corpus: themes, several masters and layouts, shape effects (shadow, glow, soft edge, +//! reflection) and hyperlinks and actions on text and shapes. +//! +//! The decks are built as PowerPoint writes them: a DeckCraft package whose theme, slides and +//! relationships are replaced with PowerPoint-shaped XML, and a two-master deck built in the model. +//! The check is open → save → reopen with nothing lost. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::style::Effects; +use deckcraft_model::text::{Action, Hyperlink}; +use deckcraft_model::{LayoutType, Presentation, Shape, defaults}; + +/// Replace (or add) parts of a package. +fn repack(bytes: &[u8], parts: &[(&str, String)]) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(vec![])); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + if parts.iter().any(|(n, _)| *n == name) { + continue; + } + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + out.start_file(name, opts).expect("start"); + out.write_all(&data).expect("write"); + } + for (n, xml) in parts { + out.start_file(*n, opts).expect("start"); + out.write_all(xml.as_bytes()).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut s = String::new(); + z.by_name(name).expect(name).read_to_string(&mut s).expect("utf8"); + s +} + +fn round_trip(p: &Presentation) -> Presentation { + deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("reimport") +} + +fn shapes_named<'a>(p: &'a Presentation, slide: usize) -> impl Fn(&str) -> &'a Shape { + move |n| p.slides[slide].shapes.iter().find(|s| s.name == n).unwrap_or_else(|| panic!("no shape {n}")) +} + +const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; + +fn sp(id: u32, name: &str, nv_extra: &str, x: u32, effects: &str, body: &str) -> String { + format!( + r#"{nv_extra}{effects}{body}"# + ) +} + +/// A two-slide PowerPoint-style deck: a custom theme, effects on four shapes, and text and shape +/// hyperlinks of every common kind. +fn effects_and_links() -> Vec { + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + for _ in 0..2 { + let s = defaults::new_slide(&mut p, layout).expect("slide"); + p.slides.push(Arc::new(s)); + } + p.slides.truncate(2); + let base = deckcraft_pptx::export(&p).expect("export"); + let rels = part(&base, "ppt/slides/_rels/slide1.xml.rels"); + let rels = rels.replace( + "", + r#""#, + ); + let shadow = sp( + 10, + "Shadowed", + r#""#, + 254000, + r#""#, + r#"Shadow"#, + ); + let glow = sp( + 11, + "Glowing", + "", + 3048000, + r#""#, + r#"Report and appendix"#, + ); + let soft = sp( + 12, + "Soft edged", + "", + 5842000, + r#""#, + r#"Next mail us"#, + ); + let refl = sp( + 13, + "Reflected", + "", + 8636000, + r#""#, + r#"Hover me"#, + ); + let slide = format!( + r#"{shadow}{glow}{soft}{refl}"# + ); + repack(&base, &[("ppt/slides/slide1.xml", slide), ("ppt/slides/_rels/slide1.xml.rels", rels)]) +} + +fn fx(s: &Shape) -> Effects { + s.effects.clone().unwrap_or_default() +} + +fn run_links(s: &Shape) -> Vec<(String, Option)> { + s.text.iter().flat_map(|t| t.paragraphs.iter()).flat_map(|p| p.runs.iter()).map(|r| (r.text.clone(), r.props.link.clone())).collect() +} + +#[test] +fn effects_and_links_survive_a_round_trip() { + let bytes = effects_and_links(); + let a = deckcraft_pptx::import(&bytes).expect("import"); + let get = shapes_named(&a, 0); + // Read as PowerPoint wrote them. + let sh = fx(get("Shadowed")).outer_shadow.expect("shadow"); + assert!((sh.blur - 6.0).abs() < 1e-6 && (sh.dist - 3.0).abs() < 1e-6 && (sh.dir - 45.0).abs() < 1e-6, "{sh:?}"); + assert_eq!((sh.align.as_str(), sh.rotate_with_shape), ("tl", false)); + assert!((sh.sx - 1.02).abs() < 1e-6); + assert!(fx(get("Glowing")).glow.is_some_and(|g| (g.radius - 11.0).abs() < 1e-6)); + assert_eq!(fx(get("Soft edged")).soft_edge, Some(5.0)); + let r = fx(get("Reflected")).reflection.expect("reflection"); + assert!((r.end_pos - 0.555).abs() < 1e-6 && (r.dist - 4.0).abs() < 1e-6, "{r:?}"); + let shape = get("Shadowed"); + assert!(matches!(shape.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::EndShow, true)))); + assert!( + matches!(shape.hover.as_ref().map(|h| (&h.action, h.tooltip.as_str())), Some((Action::Url { url }, "Hover for the report")) if url.contains("q=1&r=2")) + ); + let links = run_links(get("Glowing")); + assert!(matches!(&links[0].1, Some(Hyperlink { action: Action::Url { .. }, tooltip, .. }) if tooltip == "Open the report")); + assert!(links[1].1.is_none()); + assert!(matches!(&links[2].1, Some(Hyperlink { action: Action::Slide { slide }, .. }) if *slide == a.slides[1].id)); + let links = run_links(get("Soft edged")); + assert!(matches!(&links[0].1, Some(Hyperlink { action: Action::NextSlide, .. }))); + assert!(matches!(&links[1].1, Some(Hyperlink { action: Action::Url { url }, .. }) if url.starts_with("mailto:"))); + let hover = get("Reflected").text.as_ref().and_then(|t| t.paragraphs[0].runs[0].props.hover.clone()); + assert!(matches!(hover, Some(Hyperlink { action: Action::Url { .. }, ref tooltip, .. }) if tooltip == "Preview"), "{hover:?}"); + + // Saved and reopened: the same effects and links. + let b = round_trip(&a); + let (ga, gb) = (shapes_named(&a, 0), shapes_named(&b, 0)); + for n in ["Shadowed", "Glowing", "Soft edged", "Reflected"] { + assert_eq!(fx(ga(n)), fx(gb(n)), "effects of {n}"); + assert_eq!(ga(n).click, gb(n).click, "click of {n}"); + assert_eq!(ga(n).hover, gb(n).hover, "hover of {n}"); + let la: Vec<_> = run_links(ga(n)) + .into_iter() + .map(|(t, l)| (t, l.map(|h| (format!("{:?}", h.action).replace(&format!("{:?}", a.slides[1].id), "S2"), h.tooltip)))) + .collect(); + let lb: Vec<_> = run_links(gb(n)) + .into_iter() + .map(|(t, l)| (t, l.map(|h| (format!("{:?}", h.action).replace(&format!("{:?}", b.slides[1].id), "S2"), h.tooltip)))) + .collect(); + assert_eq!(la, lb, "text links of {n}"); + let ha: Vec<_> = ga(n).text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.hover.clone()).collect(); + let hb: Vec<_> = gb(n).text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.hover.clone()).collect(); + assert_eq!(ha, hb, "text hover of {n}"); + } + let out = deckcraft_pptx::export(&b).expect("export"); + assert!(part(&out, "ppt/slides/slide1.xml").contains("hlinkMouseOver")); +} + +/// Two masters with their own themes (colours, fonts) and layouts, slides on each. +fn two_masters() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut second = (*p.masters[0]).clone(); + second.id = deckcraft_model::MasterId(p.alloc_id().expect("id")); + second.name = "Brand Dark".into(); + second.theme.name = "Brand Dark Theme".into(); + second.theme.colors.name = "Brand Colours".into(); + second.theme.colors.colors[4] = deckcraft_model::Rgba::rgb(0x12, 0x34, 0x56); + second.theme.colors.colors[2] = deckcraft_model::Rgba::rgb(0x20, 0x20, 0x30); + second.theme.fonts.name = "Brand Fonts".into(); + second.theme.fonts.major.latin = "Georgia".into(); + second.theme.fonts.minor.latin = "Verdana".into(); + second.background = Some(deckcraft_model::Background::Fill { + fill: deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(0x10, 0x10, 0x18))), + }); + for l in &mut second.layouts { + l.id = deckcraft_model::LayoutId(p.alloc_id().expect("id")); + l.name = format!("Dark {}", l.name); + } + second.layouts.truncate(4); + let dark_layout = second.layouts[1].id; + p.masters.push(Arc::new(second)); + let light_layout = p.masters[0].layouts[0].id; + let s1 = defaults::new_slide(&mut p, light_layout).expect("slide"); + let s2 = defaults::new_slide(&mut p, dark_layout).expect("slide"); + p.slides = vec![Arc::new(s1), Arc::new(s2)]; + p +} + +#[test] +fn themes_masters_and_layouts_survive_a_round_trip() { + let a = two_masters(); + let b = round_trip(&a); + assert_eq!(b.masters.len(), 2); + for (ma, mb) in a.masters.iter().zip(&b.masters) { + assert_eq!(ma.name, mb.name); + assert_eq!(ma.theme.name, mb.theme.name); + // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. + let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; + assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); + assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); + assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); + assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); + assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); + assert_eq!(ma.background, mb.background, "background of {}", ma.name); + let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(ma), names(mb)); + } + // Each slide stays on its master's layout. + let layout_name = + |p: &Presentation, i: usize| p.masters.iter().flat_map(|m| m.layouts.iter()).find(|l| l.id == p.slides[i].layout).map(|l| l.name.clone()); + for i in 0..2 { + assert_eq!(layout_name(&a, i), layout_name(&b, i), "slide {i}"); + } + // A second save keeps the same theme and master parts. + let names = |bytes: &[u8]| { + let z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut v: Vec = + z.file_names().filter(|n| n.contains("theme") || n.contains("slideMaster") || n.contains("slideLayout")).map(str::to_string).collect(); + v.sort(); + v + }; + let c = round_trip(&b); + for (mb, mc) in b.masters.iter().zip(&c.masters) { + assert_eq!(mb.theme, mc.theme, "stable after a second save"); + // An empty map is the default one, which the writer spells out. + assert_eq!(mb.color_map, mc.color_map); + } + let once = deckcraft_pptx::export(&b).expect("export"); + let twice = deckcraft_pptx::export(&deckcraft_pptx::import(&once).expect("import")).expect("export"); + assert_eq!(names(&once), names(&twice)); +} diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index e5955c27..98069d11 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -36,8 +36,8 @@ fn png() -> Vec { /// A deck with one shape filled with a stretched picture, exported. fn deck() -> Vec { let mut p = defaults::new_presentation(None); - let media = p.add_media("tile.png", "image/png", png()); - let id = ShapeId(p.alloc_id()); + let media = p.add_media("tile.png", "image/png", png()).expect("media"); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; p.slide_mut(first).expect("slide").shapes.push(Shape { id, @@ -86,7 +86,7 @@ fn a_gradient_without_stops_is_saved_without_stops() { // must not turn it into a gradient of its own (it used to become solid white). use deckcraft_model::style::{Gradient, GradientShape}; let mut p = defaults::new_presentation(None); - let id = ShapeId(p.alloc_id()); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; p.slide_mut(first).expect("slide").shapes.push(Shape { @@ -107,7 +107,7 @@ fn a_gradient_without_stops_is_saved_without_stops() { fn a_fill_overlay_round_trips() { use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop}; let mut p = defaults::new_presentation(None); - let id = ShapeId(p.alloc_id()); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; let overlay = FillOverlay { blend: "mult".into(), @@ -150,7 +150,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() { tile: [-1.0, -1.0, 0.0, 0.0], }; let mut p = defaults::new_presentation(None); - let id = ShapeId(p.alloc_id()); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; p.slide_mut(first).expect("slide").shapes.push(Shape { id, @@ -174,7 +174,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() { #[test] fn a_table_naming_no_style_is_drawn_with_the_table_grid() { let mut p = defaults::new_presentation(None); - let id = ShapeId(p.alloc_id()); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; let mut t = deckcraft_model::Table::new(2, 2, 200.0, 40.0); t.style = "none".into(); @@ -207,8 +207,8 @@ fn a_table_naming_no_style_is_drawn_with_the_table_grid() { #[test] fn black_and_white_and_inverted_alpha_pictures_round_trip() { let mut p = defaults::new_presentation(None); - let media = p.add_media("tile.png", "image/png", png()); - let id = ShapeId(p.alloc_id()); + let media = p.add_media("tile.png", "image/png", png()).expect("media"); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; let mut pf = PictureFill { media, ..Default::default() }; pf.adjust.bilevel = Some(0.3); diff --git a/crates/pptx/tests/kept_parts.rs b/crates/pptx/tests/kept_parts.rs new file mode 100644 index 00000000..1f4ecbc3 --- /dev/null +++ b/crates/pptx/tests/kept_parts.rs @@ -0,0 +1,91 @@ +//! Custom XML data (SharePoint metadata), the sensitivity label and custom document properties +//! survive opening and saving a deck. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::Session; +use zip::write::SimpleFileOptions; + +const ITEM: &str = r#""#; +const PROPS: &str = r#""#; +const ITEM_RELS: &str = r#""#; +const LABEL: &str = r#""#; +const CUSTOM: &str = r#"0x0101"#; + +fn deck() -> Vec { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let p = s.doc().expect("doc").doc.as_ref().clone(); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).expect("read"); + let edit = |d: Vec, at: &str, add: &str| String::from_utf8(d).expect("utf8").replacen(at, &format!("{add}{at}"), 1).into_bytes(); + let data = match name.as_str() { + "[Content_Types].xml" => edit( + data, + "", + r#""#, + ), + "_rels/.rels" => edit( + data, + "", + r#""#, + ), + "ppt/_rels/presentation.xml.rels" => edit( + data, + "", + r#""#, + ), + _ => data, + }; + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + for (n, d) in [ + ("customXml/item1.xml", ITEM), + ("customXml/itemProps1.xml", PROPS), + ("customXml/_rels/item1.xml.rels", ITEM_RELS), + ("docMetadata/LabelInfo.xml", LABEL), + ("docProps/custom.xml", CUSTOM), + ] { + out.start_file(n, SimpleFileOptions::default()).expect("start"); + out.write_all(d.as_bytes()).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn read(zip: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + zip.by_name(name).unwrap_or_else(|_| panic!("{name} missing")).read_to_string(&mut s).expect("read"); + s +} + +#[test] +fn custom_xml_label_and_custom_properties_survive_save() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + assert_eq!(p.kept_parts.len(), 5); + let saved = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(Cursor::new(saved.clone())).expect("zip"); + for (n, d) in [ + ("customXml/item1.xml", ITEM), + ("customXml/itemProps1.xml", PROPS), + ("customXml/_rels/item1.xml.rels", ITEM_RELS), + ("docMetadata/LabelInfo.xml", LABEL), + ("docProps/custom.xml", CUSTOM), + ] { + assert_eq!(read(&mut zip, n), d, "{n}"); + } + let ct = read(&mut zip, "[Content_Types].xml"); + assert!(ct.contains("/docMetadata/LabelInfo.xml") && ct.contains("customXmlProperties+xml") && ct.contains("custom-properties+xml")); + let root = read(&mut zip, "_rels/.rels"); + assert!(root.contains("classificationlabels") && root.contains(r#"Target="docProps/custom.xml""#)); + assert!(read(&mut zip, "ppt/_rels/presentation.xml.rels").contains(r#"Target="../customXml/item1.xml""#)); + // And again: a second save keeps them once. + let again = deckcraft_pptx::import(&saved).expect("reimport"); + assert_eq!(again.kept_parts, p.kept_parts); +} diff --git a/crates/pptx/tests/locks.rs b/crates/pptx/tests/locks.rs index 2e903e60..60e0d874 100644 --- a/crates/pptx/tests/locks.rs +++ b/crates/pptx/tests/locks.rs @@ -15,14 +15,14 @@ fn png() -> Vec { } fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape { - Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() } + Shape { id: ShapeId(p.alloc_id().expect("ids")), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() } } /// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a /// locked group (one child unlocked, one hidden), and a locked picture, connector and table. fn deck() -> Presentation { let mut p = defaults::new_presentation(None); - let img = p.add_media("image1.png", "image/png", png()); + let img = p.add_media("image1.png", "image/png", png()).expect("media"); let mut v = vec![]; let mut a = shape(&mut p, "Locked", 0.0); a.locked = true; @@ -40,7 +40,7 @@ fn deck() -> Presentation { c3.hidden = true; let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0); let mut g = Shape { - id: ShapeId(p.alloc_id()), + id: ShapeId(p.alloc_id().expect("ids")), name: "Group".into(), xfrm: Some(gx), kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx }, diff --git a/crates/pptx/tests/malformed.rs b/crates/pptx/tests/malformed.rs index e76e3201..633d339e 100644 --- a/crates/pptx/tests/malformed.rs +++ b/crates/pptx/tests/malformed.rs @@ -251,7 +251,7 @@ fn export_of_odd_models_is_valid() { } s.shapes.push(Shape { id: ShapeId(7), - kind: ShapeKind::Opaque { xml: " Vec<(String, Vec)> { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + (f.name().to_string(), d) + }) + .collect() +} + +fn rezip(es: &[(String, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, d) in es { + z.start_file(n.as_str(), o).expect("start"); + z.write_all(d).expect("write"); + } + z.finish().expect("finish").into_inner() +} + +/// Export an equation, move its `xmlns:m` from the equation to the element `host` opens, and +/// return the OMML read back. +fn moved_to(host: &str) -> String { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id().expect("id")); + let omml = format!("x=1"); + let eq = Shape { + id, + name: "Equation".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 40.0)), + text: Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![Run { text: "x=1".into(), props: RunProps::default(), kind: RunKind::Math { omml } }], + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = vec![eq]; + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + let xml = String::from_utf8(slide.1.clone()).expect("utf8"); + assert!(xml.contains(host), "{xml}"); + let xml = xml.replacen(&format!(" {NS_M}"), "", 1).replacen(host, &format!("{host}{NS_M} "), 1); + assert_eq!(xml.matches(NS_M).count(), 1); + slide.1 = xml.into_bytes(); + + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + let shape = q.slides[0].shapes.iter().find(|s| s.name == "Equation").expect("shape"); + let run = shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).expect("run"); + match &run.kind { + RunKind::Math { omml } => omml.clone(), + other => panic!("{other:?}"), + } +} + +#[test] +fn math_namespace_declared_on_the_slide_root_is_kept() { + let omml = moved_to(" +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + let mut p = defaults::new_presentation(None); + Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut found = false; + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + if name == NOTES { + data = NOTES_XML.as_bytes().to_vec(); + found = true; + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + assert!(found, "the export has no {NOTES}"); + out.finish().expect("finish").into_inner() +} + +fn assert_formatted(notes: &TextBody) { + assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač"); + let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") }; + assert_eq!(head.runs[0].props.bold, Some(true), "bold heading"); + for para in [head, plain, closing] { + assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text()); + } + assert_eq!(plain.runs[0].props.bold, None); + for para in [goal, reflect] { + assert_eq!(para.level, 1, "level 2"); + assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU"); + assert_eq!(para.props.indent, Some(-22.5), "hanging indent"); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() })); + assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings")); + } + assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label"); + assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain"); + assert_eq!(reflect.runs[0].props.italic, Some(true)); + assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5))); +} + +#[test] +fn notes_import_keeps_bold_levels_and_indents() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + assert_formatted(&p.slides[0].notes); +} + +#[test] +fn notes_formatting_survives_a_round_trip() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport"); + assert_formatted(&again.slides[0].notes); + // And the written XML says so, for PowerPoint. + let bytes = deckcraft_pptx::export(&again).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8"); + assert!(xml.contains(r#"b="1""#), "{xml}"); + assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}"); + assert!(xml.contains(r#""), "{xml}"); +} diff --git a/crates/pptx/tests/ole_vml.rs b/crates/pptx/tests/ole_vml.rs index 8f6c007b..ccdc803a 100644 --- a/crates/pptx/tests/ole_vml.rs +++ b/crates/pptx/tests/ole_vml.rs @@ -27,6 +27,11 @@ fn png(v: u8) -> Vec { /// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures. fn deck() -> Vec { + deck_with(FRAME) +} + +/// [`deck`] with `frames` in place of [`FRAME`]. +fn deck_with(frames: &str) -> Vec { let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -39,7 +44,7 @@ fn deck() -> Vec { if name == "ppt/slides/slide1.xml" { let s = String::from_utf8(d).expect("utf8"); let at = s.rfind("").expect("spTree"); - let s = format!("{}{FRAME}{}", &s[..at], &s[at..]); + let s = format!("{}{frames}{}", &s[..at], &s[at..]); let at = s.rfind("").expect("cSld"); d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes(); } else if name == "ppt/slides/_rels/slide1.xml.rels" { @@ -82,3 +87,32 @@ fn activex_controls_show_their_pictures() { assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0)); assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing"); } + +#[test] +fn many_objects_sharing_one_vml_drawing_each_get_their_preview() { + // 200 frames look up the same drawing (it is parsed once); every other one names a shape id + // the drawing doesn't have. + let frames: String = (0..200) + .map(|i| { + let spid = match i % 4 { + 0 => "_x0000_s2050", + 1 => "_x0000_s9999", + 2 => "_x0000_s2049", + _ => "missing", + }; + FRAME.replace("id=\"90\" name=\"Object 3\"", &format!("id=\"{}\" name=\"Obj {i}\"", 1000 + i)).replace("_x0000_s2050", spid) + }) + .collect(); + let p = deckcraft_pptx::import(&deck_with(&frames)).expect("import"); + for i in 0..200 { + let s = p.slides[0].shapes.iter().find(|s| s.name == format!("Obj {i}")).expect("frame"); + let ShapeKind::Opaque { preview, .. } = &s.kind else { panic!("{:?}", s.kind) }; + let name = preview.and_then(|m| p.media.iter().find(|x| x.id == m)).map(|m| m.name.as_str()); + let want = match i % 4 { + 0 => Some("preview.png"), + 2 => Some("other.png"), + _ => None, + }; + assert_eq!(name, want, "frame {i}"); + } +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..697dad20 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -27,12 +27,12 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { } fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { - Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } + Shape { id: ShapeId(p.alloc_id().expect("ids")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } } fn new_slide(p: &mut Presentation, title: &str) -> Slide { let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default(); - let mut s = defaults::new_slide(p, layout); + let mut s = defaults::new_slide(p, layout).expect("slide"); if let Some(t) = s.shapes.first_mut() { t.text = Some(TextBody::from_text(title)); } @@ -45,9 +45,9 @@ pub fn rich_deck() -> Presentation { p.props.title = "Rich deck".into(); p.props.author = "DeckCraft tests".into(); p.props.created = "2025-01-02T03:04:05Z".into(); - let img = p.add_media("image1.png", "image/png", png(40, 30, [200, 40, 40])); - let img2 = p.add_media("image2.png", "image/png", png(8, 8, [20, 140, 220])); - let video = p.add_media("clip.mp4", "video/mp4", b"\x00\x00\x00\x18ftypmp42not-a-real-video".to_vec()); + let img = p.add_media("image1.png", "image/png", png(40, 30, [200, 40, 40])).expect("media"); + let img2 = p.add_media("image2.png", "image/png", png(8, 8, [20, 140, 220])).expect("media"); + let video = p.add_media("clip.mp4", "video/mp4", b"\x00\x00\x00\x18ftypmp42not-a-real-video".to_vec()).expect("media"); // Slide 1: title slide with notes and formatted text. { @@ -82,6 +82,9 @@ pub fn rich_deck() -> Presentation { action: Action::Url { url: "https://example.org/a?b=1&c=2".into() }, tooltip: "tip".into(), highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, }), ..Default::default() }, @@ -153,7 +156,14 @@ pub fn rich_deck() -> Presentation { }], ..Default::default() }); - b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true }); + b.click = Some(Hyperlink { + action: Action::NextSlide, + tooltip: String::new(), + highlight_click: true, + sound: None, + stop_sound: false, + zoom_return: None, + }); let mut c = shape(&mut p, "Freeform", 450.0, 120.0, 100.0, 100.0); c.geom = Geom::Custom { paths: vec![CustomPath { @@ -232,6 +242,7 @@ pub fn rich_deck() -> Presentation { alpha: Some(0.5), adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() }, upright: false, + artistic: None, }, }; let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0); @@ -328,7 +339,7 @@ pub fn rich_deck() -> Presentation { // Slide 6: bulleted content with a by-paragraph build, a slide link and an equation. let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::TitleAndContent).unwrap_or_default(); - let mut s = defaults::new_slide(&mut p, layout); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); s.shapes[0].text = Some(TextBody::from_text("Build")); s.shapes[1].text = Some(TextBody::from_text("One\nTwo\nThree")); let body = s.shapes[1].id; @@ -336,7 +347,14 @@ pub fn rich_deck() -> Presentation { let target = p.slides[1].id; let mut link = shape(&mut p, "Go to shapes", 600.0, 450.0, 200.0, 40.0); link.text = Some(TextBody::from_text("Back to shapes")); - link.click = Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false }); + link.click = Some(Hyperlink { + action: Action::Slide { slide: target }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); let mut eq = shape(&mut p, "Equation", 100.0, 450.0, 300.0, 40.0); eq.text = Some(TextBody { paragraphs: vec![Paragraph { @@ -487,9 +505,40 @@ fn pictures_media_and_ink_round_trip() { assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250)); assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp"))); assert!(m.poster.is_some()); - // Ink comes back as freeform lines in a group. + // Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width. let ink = by_name(&q.slides[2], "Ink"); - assert!(matches!(&ink.kind, ShapeKind::Group { children, .. } if children.len() == 1 && matches!(children[0].geom, Geom::Custom { .. }))); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) }; + assert_eq!(children.len(), 1); + assert!(matches!(children[0].geom, Geom::Custom { .. })); + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}"); +} + +#[test] +fn pressured_ink_exports_at_its_mean_width() { + let mut p = Presentation::default(); + let id = ShapeId(p.alloc_id().expect("id")); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + slide.shapes.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![InkStroke { + points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)], + color: Rgba::rgb(0, 0, 0), + width: 10.0, + highlighter: false, + }], + }, + ..Default::default() + }); + let q = round(&p); + let ink = by_name(&q.slides[0], "Ink"); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) }; + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + // Full, half, and the 8% floor: (10 + 5 + 0.8) / 3. + assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}"); } #[test] @@ -589,7 +638,7 @@ fn every_transition_kind_survives() { p.slides.clear(); for (kind, ..) in deckcraft_model::anim::TRANSITIONS { let mut s = base.clone(); - s.id = SlideId(p.alloc_id()); + s.id = SlideId(p.alloc_id().expect("ids")); s.transition = Some(Transition { kind: kind.to_string(), duration_ms: 1200, ..Default::default() }); p.slides.push(Arc::new(s)); } @@ -635,6 +684,23 @@ fn every_animation_effect_survives() { } } +#[test] +fn animation_rewind_survives() { + let mut p = defaults::new_presentation(None); + let mut s = p.slides[0].as_ref().clone(); + let sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0); + let id = sh.id; + s.shapes.push(sh); + s.animations.push(Animation { shape: id, rewind: true, ..Default::default() }); + s.animations.push(Animation { shape: id, effect: "fly".into(), option: "l".into(), start: AnimStart::WithPrevious, ..Default::default() }); + p.slides[0] = Arc::new(s); + let q = round(&p); + let b = &q.slides[0].animations; + assert_eq!(b.len(), 2); + assert!(b[0].rewind); + assert!(!b[1].rewind); +} + /// Writes bisection variants of the rich deck to `DECKCRAFT_PPTX_BISECT` (manual checks). #[test] fn bisect_variants() { @@ -754,7 +820,7 @@ fn colour_map_overrides_round_trip() { #[test] fn a_picture_duotone_round_trips() { let mut p = defaults::new_presentation(None); - let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])); + let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])).expect("media"); let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0); let mut pf = PictureFill { media: img, ..Default::default() }; pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))); @@ -867,7 +933,7 @@ fn transition_deck(list: &[(&str, &str, u32, Option)]) -> (Presentation, Ve p.slides.clear(); for (kind, option, ms, adv) in list { let mut s = base.clone(); - s.id = SlideId(p.alloc_id()); + s.id = SlideId(p.alloc_id().expect("ids")); s.transition = Some(Transition { kind: kind.to_string(), option: option.to_string(), @@ -991,3 +1057,866 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +#[test] +fn linked_pictures_are_written_with_r_link() { + // A picture whose bytes live outside the package must be referenced with `r:link`; `r:embed` + // may only name a part inside the package (PowerPoint won't load the linked image otherwise). + let mut p = defaults::new_presentation(None); + let media = deckcraft_model::MediaId(5000); + p.media.push(deckcraft_model::MediaItem { + id: media, + name: "linked.png".into(), + content_type: "image/png".into(), + data: Arc::new(Vec::new()), + link: Some("file:///tmp/linked.png".into()), + }); + let mut pic = shape(&mut p, "Linked", 100.0, 100.0, 200.0, 150.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }; + Arc::make_mut(&mut p.slides[0]).shapes.push(pic); + + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read"); + assert!(xml.contains(r#"r:link="rId"#), "{xml}"); + assert!(!xml.contains(r#"r:embed="rId"#), "{xml}"); + let mut rels = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/_rels/slide1.xml.rels").expect("rels"), &mut rels).expect("read"); + assert!(rels.contains(r#"Target="file:///tmp/linked.png" TargetMode="External""#), "{rels}"); +} + +/// #210: `.potx` and `.ppsx` were written with the `.pptx` main content type. +#[test] +fn templates_and_slide_shows_declare_their_own_main_content_type() { + let p = rich_deck(); + for (ext, kind) in [("pptx", "presentation"), ("potx", "template"), ("ppsx", "slideshow")] { + let bytes = deckcraft_pptx::export_as(&p, ext).expect("export"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let ct = std::io::read_to_string(z.by_name("[Content_Types].xml").expect("content types")).expect("read"); + assert!(ct.contains(&format!("presentationml.{kind}.main+xml")), "{ext}: {ct}"); + assert_eq!(deckcraft_pptx::import(&bytes).expect("import").slides.len(), p.slides.len(), "{ext} reads back"); + } +} + +/// #214: a lone guide was always written `adj`, but these presets name it `adj1`, and PowerPoint +/// won't open the file otherwise. (Names as PowerPoint writes them.) +#[test] +fn single_guide_names_follow_the_preset() { + let mut p = defaults::new_presentation(None); + let mut s = new_slide(&mut p, "Guides"); + let cases = [ + ("bentConnector3", "adj1"), + ("curvedConnector3", "adj1"), + ("frame", "adj1"), + ("mathPlus", "adj1"), + ("mathMinus", "adj1"), + ("mathMultiply", "adj1"), + ("roundRect", "adj"), + ("chevron", "adj"), + ]; + for (prst, _) in cases { + let mut sh = shape(&mut p, prst, 0.0, 0.0, 100.0, 50.0); + sh.geom = Geom::Preset { name: prst.into(), adj: vec![20000.0] }; + s.shapes.push(sh); + } + p.slides.push(Arc::new(s)); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let xml = std::io::read_to_string(z.by_name("ppt/slides/slide2.xml").expect("slide")).expect("read"); + for (prst, gd) in cases { + assert!(xml.contains(&format!(r#""#)), "{prst} → {gd}: {xml}"); + } +} + +/// #147: PowerPoint's Float Down is preset 47 (Float Up is 42); it imported as a plain fade. +#[test] +fn float_down_is_preset_47_both_ways() { + let mut p = defaults::new_presentation(None); + let mut s = new_slide(&mut p, "Float"); + let sh = shape(&mut p, "Building", 0.0, 0.0, 100.0, 100.0); + let id = sh.id; + s.shapes.push(sh); + s.animations = vec![ + Animation { shape: id, class: AnimClass::Entrance, effect: "float".into(), option: "d".into(), ..Default::default() }, + Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "d".into(), ..Default::default() }, + Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "u".into(), ..Default::default() }, + ]; + p.slides.push(Arc::new(s)); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let xml = std::io::read_to_string(z.by_name("ppt/slides/slide2.xml").expect("slide")).expect("read"); + assert!(xml.contains(r#"presetID="47" presetClass="entr" presetSubtype="0""#), "{xml}"); + assert!(xml.contains(r#"presetID="47" presetClass="exit" presetSubtype="0""#), "{xml}"); + assert!(xml.contains(r#"presetID="42" presetClass="exit" presetSubtype="0""#), "{xml}"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let got: Vec<(&str, &str)> = q.slides[1].animations.iter().map(|a| (a.effect.as_str(), a.option.as_str())).collect(); + assert_eq!(got, [("float", "d"), ("floatOut", "d"), ("floatOut", "u")]); +} + +#[test] +fn embedded_fonts_round_trip() { + let mut p = Presentation::default(); + let regular = p.add_media("font1.fntdata", "application/x-fontdata", b"REGULAR-FONT-BYTES-0123456789".to_vec()).expect("media"); + let bold = p.add_media("font2.fntdata", "application/x-fontdata", b"BOLD-FONT-BYTES-0123456789abcd".to_vec()).expect("media"); + p.embedded_fonts.push(deckcraft_model::EmbeddedFont { + typeface: "Deck Test Face".into(), + panose: Some("020B0604020202020204".into()), + pitch_family: Some(34), + charset: Some(0), + regular: Some(regular), + bold: Some(bold), + italic: Some(regular), + bold_italic: None, + }); + // The same typeface again is one entry: PowerPoint requires each typeface once. + p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "Deck Test Face".into(), regular: Some(bold), ..Default::default() }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/presentation.xml").expect("pres"), &mut xml).expect("read"); + assert!(xml.contains("embedTrueTypeFonts=\"1\"")); + let notes = xml.find("").expect("list"); + let dts = xml.find(""#)); + assert_eq!(xml.matches("").count(), 1); + let mut rels = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/_rels/presentation.xml.rels").expect("rels"), &mut rels).expect("read"); + assert_eq!(rels.matches("relationships/font").count(), 2, "regular and italic share one part"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + assert_eq!(q.embedded_fonts.len(), 1); + let f = &q.embedded_fonts[0]; + assert_eq!(f.typeface, "Deck Test Face"); + assert_eq!(f.panose.as_deref(), Some("020B0604020202020204")); + assert_eq!(f.pitch_family, Some(34)); + assert_eq!(f.charset, Some(0)); + assert_eq!(q.media(f.regular.expect("regular")).expect("media").data.as_slice(), b"REGULAR-FONT-BYTES-0123456789"); + assert_eq!(q.media(f.bold.expect("bold")).expect("media").data.as_slice(), b"BOLD-FONT-BYTES-0123456789abcd"); + assert_eq!(f.italic, f.regular); + assert!(f.bold_italic.is_none()); +} + +/// A package whose font list this writer did not produce: bold only, relationship id not `rIdN`. +#[test] +fn an_embedded_font_list_already_in_the_package_is_read() { + let bytes = deckcraft_pptx::export(&Presentation::default()).expect("export"); + let font = b"FACE-BYTES-FROM-THE-PACKAGE"; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + std::io::Read::read_to_end(&mut f, &mut data).expect("read"); + if name == "ppt/presentation.xml" { + let xml = String::from_utf8(data).expect("utf8"); + let xml = xml.replacen( + ""#, + r#"", + r#""#, + 1, + ); + data = xml.into_bytes(); + } else if name == "[Content_Types].xml" { + let xml = String::from_utf8(data).expect("utf8"); + let xml = xml.replacen("", r#""#, 1); + data = xml.into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, &data).expect("write"); + } + out.start_file("ppt/fonts/font1.fntdata", zip::write::SimpleFileOptions::default()).expect("font"); + std::io::Write::write_all(&mut out, font).expect("font bytes"); + let patched = out.finish().expect("finish").into_inner(); + let p = deckcraft_pptx::import(&patched).expect("import"); + let f = p.embedded_fonts.first().expect("font"); + assert_eq!(f.typeface, "Package Face"); + assert_eq!(f.pitch_family, Some(2)); + assert!(f.panose.is_none() && f.regular.is_none() && f.italic.is_none() && f.bold_italic.is_none()); + assert_eq!(p.media(f.bold.expect("bold")).expect("media").data.as_slice(), font); +} + +/// Text transparency (#32) is `a:alpha` on the run's colour, and survives a save. +#[test] +fn text_transparency_round_trips() { + use deckcraft_engine::Session; + let mut s = Session::with_new(); + s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); + s.execute("format.color", &serde_json::json!({"color": "#336699"})).unwrap(); + s.execute("format.textTransparency", &serde_json::json!({"value": 0.4})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let sh = back.slides[0].shapes.iter().find(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("Hi"))).unwrap(); + let Some(deckcraft_model::Fill::Solid { color }) = &sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.fill else { panic!() }; + assert!((color.alpha() - 0.6).abs() < 1e-9, "{color:?}"); +} + +/// A tiny 8 kHz mono 16-bit WAV of `n` silent samples. +fn wav(n: u32) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + n * 2).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&8000u32.to_le_bytes()); + v.extend_from_slice(&16000u32.to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&16u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&(n * 2).to_le_bytes()); + v.resize(v.len() + n as usize * 2, 0); + v +} + +/// Transitions › Sound: a chosen sound is embedded, saved as `p:sndAc`, read back, and No Sound +/// removes it; a file that isn't audio is refused. +#[test] +fn transition_sound_is_set_saved_and_removed() { + use deckcraft_engine::Session; + use serde_json::json; + let b64 = deckcraft_engine::cmd::base64_encode(&wav(800)); + let mut s = Session::with_new(); + s.execute("transition.set", &json!({"kind": "fade"})).unwrap(); + let r = s.execute("transition.sound", &json!({"data": b64, "name": "chime.wav"})).unwrap(); + assert_eq!(r["playable"], true, "{r}"); + let doc = s.doc().unwrap().doc.clone(); + let m = doc.slides[0].transition.as_ref().and_then(|t| t.sound).expect("sound set"); + assert_eq!(doc.media(m).map(|i| i.name.as_str()), Some("chime.wav")); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let t = back.slides[0].transition.as_ref().expect("transition"); + assert_eq!(t.kind, "fade"); + let m = t.sound.expect("sound survives the save"); + assert_eq!(back.media(m).map(|i| i.data.len()), Some(wav(800).len())); + s.execute("transition.sound", &json!({"none": true})).unwrap(); + assert!(s.doc().unwrap().doc.slides[0].transition.as_ref().is_some_and(|t| t.sound.is_none())); + let not_audio = deckcraft_engine::cmd::base64_encode(b"hello, not a sound"); + assert!(s.execute("transition.sound", &json!({"data": not_audio, "name": "notes.txt"})).is_err()); +} + +/// Built-in transition sounds, Loop Until Next Sound and [Stop Previous Sound] are set, saved as +/// PowerPoint writes them (`p:stSnd loop="1"`, `p:endSnd`) and read back. +#[test] +fn transition_sound_options_round_trip() { + use deckcraft_engine::Session; + use serde_json::json; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.sound", &json!({"builtin": "chime", "loop": true, "index": 0})).unwrap(); + s.execute("transition.sound", &json!({"builtin": "Chime", "index": 1})).unwrap(); + s.execute("transition.sound", &json!({"stopPrevious": true, "index": 2})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.media.len(), 1, "the same built-in sound is embedded once"); + assert_eq!(doc.media[0].name, "Chime.wav"); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let slide1 = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut x = String::new(); + std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut x).unwrap(); + x + }; + assert!(slide1.contains(r#""#) && slide1.contains(r#"name="Chime.wav""#), "{slide1}"); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let t: Vec<_> = back.slides.iter().map(|s| s.transition.clone().unwrap_or_default()).collect(); + assert!(t[0].sound.is_some() && t[0].sound_loop && !t[0].stop_sound); + assert!(t[1].sound.is_some() && !t[1].sound_loop); + assert!(t[2].sound.is_none() && t[2].stop_sound, "{:?}", t[2]); + // Turning the loop off alone keeps the sound. + s.execute("transition.sound", &json!({"loop": false, "index": 0})).unwrap(); + let t0 = s.doc().unwrap().doc.slides[0].transition.clone().unwrap(); + assert!(t0.sound.is_some() && !t0.sound_loop); + assert!(s.execute("transition.sound", &json!({"builtin": "foghorn"})).is_err()); +} + +/// Replace `from` with `to` in every slide part of an exported deck. +fn patch_slides(bytes: Vec, from: &str, to: &str) -> Vec { + use std::io::{Read, Write}; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + data = String::from_utf8(data).expect("utf8").replace(from, to).into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn runs_of(p: &Presentation, needle: &str) -> Vec<(RunKind, String)> { + for s in &p.slides { + for sh in &s.shapes { + let Some(t) = &sh.text else { continue }; + for para in &t.paragraphs { + if para.runs.iter().any(|r| r.text.contains(needle)) { + return para.runs.iter().map(|r| (r.kind.clone(), r.text.clone())).collect(); + } + } + } + } + panic!("no paragraph with {needle}"); +} + +#[test] +fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() { + let mut p = defaults::new_presentation(None); + let mut s = shape(&mut p, "Code", 10.0, 10.0, 400.0, 100.0); + s.text = Some(TextBody::from_text("CRLFHERE")); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(s); + let bytes = patch_slides(deckcraft_pptx::export(&p).expect("export"), "CRLFHERE", "one\r\n two\rthree"); + let a = deckcraft_pptx::import(&bytes).expect("import"); + let want = vec![ + (RunKind::Text, "one".to_string()), + (RunKind::Break, String::new()), + (RunKind::Text, " two".to_string()), + (RunKind::Break, String::new()), + (RunKind::Text, "three".to_string()), + ]; + assert_eq!(runs_of(&a, "one"), want); + assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs"); +} + +#[test] +fn color_pulse_keeps_its_effect_colour_and_duration() { + let mut p = defaults::new_presentation(None); + let s = shape(&mut p, "Pulsed", 10.0, 10.0, 200.0, 100.0); + let id = s.id; + let first = p.slides[0].id; + let sl = p.slide_mut(first).expect("slide"); + sl.shapes.push(s); + let white = ColorRef::rgb(Rgba::rgb(255, 255, 255)); + sl.animations.push(Animation { + shape: id, + class: AnimClass::Emphasis, + effect: "colorPulse".into(), + duration_ms: 1000, + color: Some(white.clone()), + ..Default::default() + }); + let q = round(&p); + let a = &q.slides[0].animations[0]; + assert_eq!((a.class, a.effect.as_str()), (AnimClass::Emphasis, "colorPulse")); + assert_eq!(a.color, Some(white)); + assert_eq!(a.amount, None, "no grow/shrink scale is invented"); + assert_eq!(a.duration_ms, 1000); +} + +#[test] +fn notes_master_placeholders_and_colour_map_survive_save() { + let mut p = defaults::new_presentation(None); + let mut box1 = shape(&mut p, "Confidential", 100000.0, 200000.0, 3000000.0, 400000.0); + box1.text = Some(TextBody::from_text("Company confidential")); + let map: Vec<(String, String)> = [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")] + .iter() + .chain( + [ + ("accent1", "accent1"), + ("accent2", "accent2"), + ("accent3", "accent3"), + ("accent4", "accent4"), + ("accent5", "accent5"), + ("accent6", "accent6"), + ("hlink", "hlink"), + ("folHlink", "folHlink"), + ] + .iter(), + ) + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(); + let m = deckcraft_model::Master { name: "Notes Master".into(), shapes: vec![box1], color_map: map.clone(), ..Default::default() }; + p.notes_master = Some(Arc::new(m)); + // No slide has notes: the notes master must still be written. + let q = round(&p); + let nm = q.notes_master.as_deref().expect("notes master kept"); + assert_eq!(nm.shapes.len(), 1, "own shapes, not the defaults"); + let sh = &nm.shapes[0]; + assert_eq!(sh.name, "Confidential"); + assert_eq!(sh.text.as_ref().map(|t| t.text()), Some("Company confidential".to_string())); + assert_eq!(nm.color_map, map); +} + +/// A part's bytes, if the package has it. +fn pkg_part(pkg: &[u8], name: &str) -> Option> { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut f = z.by_name(name).ok()?; + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + Some(v) +} + +/// Rewrite a package's parts with `edit`, then add `extra` parts. +fn repack(pkg: &[u8], edit: impl Fn(&str, String) -> String, extra: &[(&str, &[u8])]) -> Vec { + use std::io::{Read, Write}; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).unwrap(); + if name.ends_with(".xml") || name.ends_with(".rels") { + data = edit(&name, String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, opts).unwrap(); + out.write_all(&data).unwrap(); + } + for (name, data) in extra { + out.start_file(*name, opts).unwrap(); + out.write_all(data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +const WATERFALL_CX: &str = r#" +Sheet1!$A$2:$A$5StartSalesCostsEndSheet1!$B$2:$B$510040-25115Cash flowSheet1!$B$1Amount"#; + +/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback. +const WATERFALL_FRAME: &str = r#"This chart isn't available in your version of PowerPoint."#; + +/// Office 2016 charts (`chartEx`): a waterfall reads as a waterfall chart with its data, not as +/// the "isn't available" fallback text, and an unchanged one is written back as itself with a +/// plain chart fallback; an edited one is written as a plain chart. +#[test] +fn chartex_waterfall_reads_and_round_trips() { + use deckcraft_engine::Session; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + let base = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let slide = (1..=9).map(|i| format!("ppt/slides/slide{i}.xml")).find(|n| pkg_part(&base, n).is_some()).unwrap(); + let slide_rels = slide.replace("slides/", "slides/_rels/") + ".rels"; + let cx_rels = r#""#; + let pptx = repack( + &base, + |name, x| { + if name == slide { + x.replacen("", &format!("{WATERFALL_FRAME}"), 1) + } else if name == slide_rels { + x.replace("", r#""#) + } else if name == "[Content_Types].xml" { + x.replace( + "", + r#""#, + ) + } else { + x + } + }, + &[("ppt/charts/chartEx1.xml", WATERFALL_CX.as_bytes()), ("ppt/charts/_rels/chartEx1.xml.rels", cx_rels.as_bytes())], + ); + let doc = deckcraft_pptx::import(&pptx).unwrap(); + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let c = chart(&doc).expect("the waterfall is a chart, not the fallback text"); + assert_eq!(c.kind, ChartType::Waterfall); + assert_eq!(c.title.as_deref(), Some("Cash flow")); + assert_eq!(c.categories, ["Start", "Sales", "Costs", "End"]); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].name, "Amount"); + assert_eq!(c.series[0].values, [Some(100.0), Some(40.0), Some(-25.0), Some(115.0)]); + assert!(c.data_labels); + assert_eq!(c.legend.as_deref(), Some("b")); + let no_fallback_text = doc.slides.iter().flat_map(|s| s.shapes.iter()).all(|sh| !format!("{:?}", sh.kind).contains("isn't available")); + assert!(no_fallback_text); + + // Unchanged: chartEx back in a cx1 choice, a plain chart as fallback, no workbook link. + let out = deckcraft_pptx::export(&doc).unwrap(); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#) && !ex.contains("externalData"), "{ex}"); + let types = String::from_utf8(pkg_part(&out, "[Content_Types].xml").unwrap()).unwrap(); + assert!(types.contains("application/vnd.ms-office.chartex+xml")); + let sx = String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap(); + assert!(sx.contains(r#"Requires="cx1""#) && sx.contains("mc:Fallback") && sx.contains("c:chart "), "{sx}"); + let srels = String::from_utf8(pkg_part(&out, &slide_rels).unwrap()).unwrap(); + assert!(srels.contains("relationships/chartEx") && srels.contains("relationships/chart\""), "{srels}"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some()); + let again = deckcraft_pptx::import(&out).unwrap(); + assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1))); + + // Edited: the kept part no longer matches, so a chartEx part is written from the model, + // still a waterfall with the new value. + let mut edited = doc.clone(); + for sl in &mut edited.slides { + for sh in &mut Arc::make_mut(sl).shapes { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.series[0].values[1] = Some(55.0); + } + } + } + let out = deckcraft_pptx::export(&edited).unwrap(); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}"); + assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); + let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(again.kind, ChartType::Waterfall); + assert_eq!(again.series[0].values[1], Some(55.0)); + assert_eq!(again.subtotals, c.subtotals); +} + +/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the +/// same type, data and options. +#[test] +fn chartex_new_charts_save_as_their_own_type() { + use deckcraft_engine::Session; + for (ty, kind) in [ + ("waterfall", ChartType::Waterfall), + ("funnel", ChartType::Funnel), + ("treemap", ChartType::Treemap), + ("sunburst", ChartType::Sunburst), + ("histogram", ChartType::Histogram), + ("pareto", ChartType::Pareto), + ("boxwhisker", ChartType::BoxWhisker), + ] { + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + let opts = match kind { + ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}), + ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}), + _ => serde_json::json!({"id": id, "title": "T"}), + }; + s.execute("chart.options", &opts).unwrap(); + let doc = &s.doc().unwrap().doc; + let before = chart(doc).unwrap(); + let out = deckcraft_pptx::export(doc).unwrap(); + assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart"); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, kind, "{ty}"); + assert_eq!(after.title.as_deref(), Some("T"), "{ty}"); + assert_eq!(after.categories, before.categories, "{ty}"); + let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 }; + assert_eq!(after.series.len(), n, "{ty}"); + assert_eq!(after.series[0].values, before.series[0].values, "{ty}"); + assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}"); + } +} + +/// A two-level treemap keeps its parent level, and a box chart its inclusive quartiles, through +/// a save and reopen. +#[test] +fn chartex_levels_and_quartiles_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for ty in ["treemap", "sunburst", "boxwhisker"] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if ty == "boxwhisker" { + s.execute("chart.options", &serde_json::json!({"id": id, "quartiles": "inclusive"})).unwrap(); + } else { + s.execute( + "chart.data", + &serde_json::json!({"id": id, "levels": [["Q1", "", "", "Q2", "", "", "Q3", ""], ["H1", "", "", "", "", "", "H2", ""]]}), + ) + .unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.category_levels, before.category_levels, "{ty}"); + assert_eq!(after.inclusive_quartiles, before.inclusive_quartiles, "{ty}"); + assert_eq!(after.category_tree(), before.category_tree(), "{ty}"); + // Unchanged since import, the part is written back as read. + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}"); + } +} + +/// Radar, stock (with and without up/down bars) and combo charts keep their own plot groups +/// through a save and reopen, written as PowerPoint writes them. +#[test] +fn radar_stock_and_combo_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for (ty, bars) in [("radar", false), ("filledradar", false), ("stock", false), ("stock", true), ("combo", false)] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if bars { + s.execute("chart.options", &serde_json::json!({"id": id, "upDownBars": true})).unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, before.kind, "{ty}"); + assert_eq!(after.up_down_bars, bars, "{ty}"); + assert_eq!(after.series.len(), before.series.len(), "{ty}"); + let kinds: Vec<_> = after.series.iter().map(|x| after.series_kind(x)).collect(); + let want: Vec<_> = before.series.iter().map(|x| before.series_kind(x)).collect(); + assert_eq!(kinds, want, "{ty}"); + for (a, b) in after.series.iter().zip(&before.series) { + assert_eq!(a.values, b.values, "{ty}"); + } + } +} + +/// Home › New Slide › Reuse Slides: slides from another .pptx land after the current slide with +/// their pictures (fresh media ids, same bytes) and animations on the renumbered shapes. By +/// default they take a destination layout; Keep Source Formatting brings the source master. +#[test] +fn reuse_slides_from_another_presentation() { + use deckcraft_engine::Session; + use serde_json::json; + let b64 = deckcraft_engine::cmd::base64_encode; + // Source: three slides, a picture with an animation on the second. + let mut src = Session::with_new(); + src.execute("slide.new", &json!({})).unwrap(); + let pic = png(8, 8, [10, 200, 30]); + src.execute("insert.picture", &json!({"data": b64(&pic), "name": "green.png"})).unwrap(); + src.execute("animation.add", &json!({"effect": "fade"})).unwrap(); + src.execute("slide.new", &json!({})).unwrap(); + let src_bytes = deckcraft_pptx::export(&src.doc().unwrap().doc).unwrap(); + + let mut s = Session::with_new(); + // Media ids in the two decks overlap; the destination's own picture must stay its own. + let red = png(4, 4, [200, 0, 0]); + s.execute("insert.picture", &json!({"data": b64(&red), "name": "red.png"})).unwrap(); + let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [1]})).unwrap(); + assert_eq!(r["slides"], 1, "{r}"); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), 2); + assert_eq!(s.doc().unwrap().selection.slide, 1, "the reused slide is selected"); + let pictures = |sl: &deckcraft_model::Slide| { + let mut v = vec![]; + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind { + v.push(fill.media); + } + }); + v + }; + let mine = pictures(&doc.slides[0]); + let reused = pictures(&doc.slides[1]); + assert_eq!(doc.media(mine[0]).map(|m| m.data.as_slice() == red.as_slice()), Some(true), "own picture untouched"); + assert_eq!(doc.media(reused[0]).map(|m| m.data.as_slice() == pic.as_slice()), Some(true), "reused picture copied"); + let anim = &doc.slides[1].animations[0]; + assert!(doc.slides[1].shapes.iter().any(|sh| sh.id == anim.shape), "animation follows its shape"); + assert!(doc.layout(doc.slides[1].layout).is_some(), "uses a destination layout"); + assert_eq!(doc.masters.len(), 1); + // The result saves and reopens. + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap(); + assert_eq!(back.slides.len(), 2); + + // Keep Source Formatting: every slide, and the source master comes along. + let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "keepFormatting": true, "at": 0})).unwrap(); + assert_eq!(r["slides"], 3); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), 5); + assert_eq!(doc.masters.len(), 2); + for sl in &doc.slides[..3] { + assert_eq!(doc.layout(sl.layout).map(|(m, _)| m.id), Some(doc.masters[1].id), "source master"); + } + // One undo removes the whole reuse. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 2); + assert!(s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [7]})).is_err()); + assert!(s.execute("slide.reuse", &json!({"data": b64(&[0u8, 159, 146, 150]), "name": "x.bin"})).is_err()); +} + +/// Picture Format › Compress Pictures: a big picture shown small is downsampled, its original +/// goes when nothing else uses it, the saved file shrinks, a copy that wasn't compressed keeps +/// the original, and undo brings everything back. +#[test] +fn compress_pictures_shrinks_the_file_and_keeps_shared_originals() { + use deckcraft_engine::Session; + use serde_json::json; + let b64 = deckcraft_engine::cmd::base64_encode; + // A noisy 1600 × 1600 picture (doesn't compress away on its own). + let big = { + let img = image::RgbImage::from_fn(1600, 1600, |x, y| image::Rgb([(x * 31 % 251) as u8, (y * 17 % 253) as u8, ((x * y) % 255) as u8])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + let mut s = Session::with_new(); + s.execute("insert.picture", &json!({"data": b64(&big), "name": "noise.png"})).unwrap(); + s.execute("shape.resize", &json!({"w": 144, "h": 144})).unwrap(); + let size_before = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap().len(); + let r = s.execute("picture.compress", &json!({"ppi": 150})).unwrap(); + assert_eq!(r["pictures"], 1, "{r}"); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.media.len(), 1, "the original is gone"); + let img = image::load_from_memory(&doc.media[0].data).unwrap(); + assert_eq!((img.width(), img.height()), (300, 300), "2 in at 150 ppi"); + let size_after = deckcraft_pptx::export(&doc).unwrap().len(); + assert!(size_after * 4 < size_before, "{size_before} → {size_after}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.media[0].data.len(), big.len(), "undo restores the original"); + + // A duplicate that isn't compressed still needs the original. + s.execute("edit.duplicate", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes.clone(); + let first: Vec<_> = s.doc().unwrap().doc.slides[0].shapes.iter().map(|sh| sh.id).filter(|id| !copy.contains(id)).collect(); + s.execute("picture.compress", &json!({"ppi": 150, "ids": [first.last().unwrap().0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.media.len(), 2); + assert!(doc.media.iter().any(|m| m.data.len() == big.len()), "the copy keeps the original"); + // All pictures: the copy gets compressed too, and the original goes. + s.execute("picture.compress", &json!({"ppi": 150, "all": true})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert!(doc.media.iter().all(|m| m.data.len() < big.len() / 4)); + assert!(s.execute("picture.compress", &json!({"ppi": 5, "all": true})).is_err()); +} + +/// #203: SmartArt we can't draw kept its XML but lost its diagram parts on save. +#[test] +fn smartart_without_a_drawing_keeps_its_parts() { + let mut p = defaults::new_presentation(None); + let data = p.add_media( + "data1.xml", + "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml", + br#"Manager"#.to_vec(), + ).expect("media"); + let mut parts = vec![("rId2".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData".to_string(), data)]; + for (i, kind) in ["Layout", "QuickStyle", "Colors"].into_iter().enumerate() { + let xml = format!(r#""#).into_bytes(); + let m = p + .add_media(&format!("{kind}1.xml"), &format!("application/vnd.openxmlformats-officedocument.drawingml.diagram{kind}+xml"), xml) + .expect("media"); + parts.push((format!("rId{}", i + 3), format!("http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagram{kind}"), m)); + } + let xml = r#""#; + let mut s = new_slide(&mut p, "SmartArt"); + let mut frame = shape(&mut p, "Diagram 3", 0.0, 0.0, 72.0, 72.0); + frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: None, label: "SmartArt".into(), parts }; + s.shapes.push(frame); + p.slides.push(Arc::new(s)); + + // Twice: the second save starts from what the first one wrote. + let q = round(&round(&p)); + let ShapeKind::Opaque { parts, .. } = &by_name(&q.slides[1], "Diagram 3").kind else { panic!("not kept as an object") }; + assert_eq!(parts.len(), 4, "data, layout, style and colours all kept"); + let data = q.media(parts.iter().find(|(_, k, _)| k.ends_with("/diagramData")).expect("data").2).expect("media"); + let data = String::from_utf8_lossy(&data.data); + assert!(data.contains("Manager"), "{data}"); + assert!(!data.contains("dataModelExt"), "the link to a drawing that isn't written is dropped: {data}"); +} + +/// #203: a legacy OLE object (preview in the VML drawing we don't write) is saved in PowerPoint's +/// fallback form, without `spid` and with a `p:pic` preview, or PowerPoint won't open the file. +#[test] +fn legacy_ole_object_is_written_in_the_fallback_form() { + let mut p = defaults::new_presentation(None); + let bin = p + .add_media("oleObject1.bin", "application/vnd.openxmlformats-officedocument.oleObject", b"\xD0\xCF\x11\xE0not-a-real-ole".to_vec()) + .expect("media"); + let prev = p.add_media("image1.png", "image/png", png(4, 3, [10, 20, 30])).expect("media"); + let xml = r#""#; + let parts = vec![("rId4".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject".to_string(), bin)]; + let mut s = new_slide(&mut p, "OLE"); + let mut frame = shape(&mut p, "Object 3", 0.0, 0.0, 72.0, 72.0); + frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: Some(prev), label: "Slide".into(), parts }; + s.shapes.push(frame); + p.slides.push(Arc::new(s)); + + let q = round(&round(&p)); + let ShapeKind::Opaque { xml, parts, preview, .. } = &by_name(&q.slides[1], "Object 3").kind else { panic!("not kept as an object") }; + assert!(xml.contains("") && !xml.contains("spid"), "{xml}"); + assert!(preview.is_some(), "the preview picture is kept"); + assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}"); +} + +/// Insert › Zoom: slide, section and summary zooms are linked pictures of their targets, with +/// Return to Zoom on section and summary zooms; a summary zoom makes its own slide; all of it is +/// one undo step and saves as linked pictures. +#[test] +fn zoom_inserts_linked_slide_pictures() { + use deckcraft_engine::Session; + use deckcraft_model::text::Action; + use serde_json::json; + let mut s = Session::with_new(); + for t in ["Intro", "Plan", "Budget", "Risks"] { + s.execute("slide.new", &json!({"title": t})).unwrap(); + } + // Slides: 0 title, 1 Intro, 2 Plan, 3 Budget, 4 Risks. + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let r = s.execute("insert.zoom", &json!({"slides": [2]})).unwrap(); + assert_eq!(r["returnToZoom"], false); + let doc = s.doc().unwrap().doc.clone(); + let zoom = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Slide Zoom")).expect("slide zoom"); + let link = zoom.click.as_ref().unwrap(); + assert_eq!(link.action, Action::Slide { slide: doc.slides[2].id }); + assert_eq!(link.zoom_return, None); + assert_eq!(link.tooltip, "Plan"); + let ShapeKind::Picture { fill } = &zoom.kind else { panic!() }; + assert!(doc.media(fill.media).is_some_and(|m| m.data.starts_with(b"\x89PNG"))); + + // Section zoom: back after the section's last slide. + s.execute("section.add", &json!({"name": "Start", "at": 0})).unwrap(); + s.execute("section.add", &json!({"name": "Money", "at": 3})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let money = doc.sections.iter().position(|x| x.name == "Money").unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + s.execute("insert.zoom", &json!({"kind": "section", "sections": [money]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let sec = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Section Zoom")).unwrap(); + let last = *doc.sections[money].slides.last().unwrap(); + assert_eq!(sec.click.as_ref().unwrap().zoom_return, Some(last)); + + // Summary zoom on slides 1 and 3: a new slide in front, parts 1–2 and 3–4. + let before = doc.slides.len(); + s.execute("insert.zoom", &json!({"kind": "summary", "slides": [1, 3]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), before + 1); + let sum = &doc.slides[1]; + assert!(sum.title().contains("Summary")); + let tiles: Vec<_> = sum.shapes.iter().filter_map(|sh| sh.click.as_ref()).collect(); + assert_eq!(tiles.len(), 2); + assert_eq!(tiles[0].zoom_return, Some(doc.slides[3].id), "part one runs to the slide before part two"); + assert_eq!(tiles[1].zoom_return, Some(doc.slides.last().unwrap().id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), before, "one undo step"); + + // Saved as linked pictures. + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap()).unwrap(); + let z = back.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Picture { .. }) && sh.click.is_some()).unwrap(); + assert!(matches!(z.click.as_ref().unwrap().action, Action::Slide { .. })); + assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err()); + assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err()); +} diff --git a/crates/pptx/tests/smartart.rs b/crates/pptx/tests/smartart.rs new file mode 100644 index 00000000..3d3dd996 --- /dev/null +++ b/crates/pptx/tests/smartart.rs @@ -0,0 +1,167 @@ +//! SmartArt drawings: the text of each pre-drawn shape is laid out on its `dsp:txXfrm`. +//! +//! The deck is built here: an export of a new presentation with a SmartArt frame, data part and +//! drawing part written by the test. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::render::place_slide; +use deckcraft_geom::{Point, Rect}; +use deckcraft_model::{Presentation, ShapeKind, defaults}; + +const EMU: f64 = 12_700.0; + +/// Frame of the diagram on the slide, in points. +const FRAME: (f64, f64, f64, f64) = (72.0, 72.0, 432.0, 288.0); + +fn emu(pt: f64) -> i64 { + (pt * EMU).round() as i64 +} + +fn xfrm(tag: &str, rot: f64, x: f64, y: f64, w: f64, h: f64) -> String { + let rot = if rot == 0.0 { String::new() } else { format!(" rot=\"{}\"", (rot * 60_000.0).round() as i64) }; + format!(r#"<{tag}{rot}>"#, emu(x), emu(y), emu(w), emu(h)) +} + +/// One `dsp:sp`: shape box, text rectangle (both in diagram points) and text. +fn drawing_shape(prst: &str, shape: (f64, f64, f64, f64, f64), text_rect: (f64, f64, f64, f64, f64), text: &str) -> String { + let (r, x, y, w, h) = shape; + let (tr, tx, ty, tw, th) = text_rect; + format!( + r#"{}{text}{}"#, + xfrm("a:xfrm", r, x, y, w, h), + xfrm("dsp:txXfrm", tr, tx, ty, tw, th), + ) +} + +fn entries(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + (f.name().to_string(), d) + }) + .collect() +} + +fn zip_of(files: &[(String, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, d) in files { + z.start_file(n.as_str(), o).expect("start"); + z.write_all(d).expect("write"); + } + z.finish().expect("finish").into_inner() +} + +fn replace(files: &mut [(String, Vec)], name: &str, from: &str, to: &str) { + let (_, d) = files.iter_mut().find(|(n, _)| n == name).unwrap_or_else(|| panic!("{name} missing")); + let s = String::from_utf8(d.clone()).expect("utf8"); + assert!(s.contains(from), "{name} has no {from}"); + *d = s.replacen(from, to, 1).into_bytes(); +} + +/// A deck whose first slide holds a SmartArt frame drawn by `shapes`. +fn smartart_deck(shapes: &[String]) -> Vec { + let mut files = entries(&deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export")); + let (fx, fy, fw, fh) = FRAME; + let frame = format!( + r#""#, + emu(fx), + emu(fy), + emu(fw), + emu(fh) + ); + replace(&mut files, "ppt/slides/slide1.xml", "", &frame); + replace( + &mut files, + "ppt/slides/_rels/slide1.xml.rels", + "", + r#""#, + ); + let data = r#" +"#; + let drawing = format!( + r#" +{}"#, + shapes.concat() + ); + files.push(("ppt/diagrams/data1.xml".into(), data.as_bytes().to_vec())); + files.push(("ppt/diagrams/drawing1.xml".into(), drawing.into_bytes())); + zip_of(&files) +} + +/// Each text of slide 1 with the slide-space box it is laid out in (insets applied), the number of +/// lines, and the direction of its baseline. +fn text_boxes(p: &Presentation) -> Vec<(String, Rect, usize, Point)> { + place_slide(p, 0) + .texts + .iter() + .map(|t| { + let m = t.transform; + let r = t.layout.inner; + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|c| m * c); + let mut bounds = Rect::from_points(corners[0], corners[2]); + bounds = bounds.union_pt(corners[1]).union_pt(corners[3]); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::new(0.0, 0.0)); + let dir = Point::new(dir.x / dir.hypot(), dir.y / dir.hypot()); + (t.body.text(), bounds, t.layout.lines.len(), dir) + }) + .collect() +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.05) +} + +/// Diagram rectangle (x, y, w, h) → slide rectangle. +fn on_slide(x: f64, y: f64, w: f64, h: f64) -> Rect { + Rect::new(FRAME.0 + x, FRAME.1 + y, FRAME.0 + x + w, FRAME.1 + y + h) +} + +fn check(p: &Presentation, expected: &[(&str, Rect)]) { + let boxes = text_boxes(p); + for (text, want) in expected { + let (_, got, lines, dir) = boxes.iter().find(|(t, ..)| t == text).unwrap_or_else(|| panic!("no text {text:?} in {boxes:?}")); + assert!(near(*got, *want), "{text:?}: laid out in {got:?}, want {want:?}"); + assert!((dir.x - 1.0).abs() < 1e-6 && dir.y.abs() < 1e-6, "{text:?}: baseline turned to {dir:?}"); + assert_eq!(*lines, 1, "{text:?}: wrapped into {lines} lines"); + } +} + +#[test] +fn smartart_text_follows_tx_xfrm() { + let deck = smartart_deck(&[ + // A narrow shape whose text rectangle is three times wider. + drawing_shape("rect", (0.0, 0.0, 0.0, 72.0, 144.0), (0.0, 0.0, 36.0, 216.0, 72.0), "wider than its shape"), + // A tall shape turned by 90° (wide on the slide), its text rectangle turned back: horizontal text. + drawing_shape("roundRect", (90.0, 300.0, -60.0, 48.0, 216.0), (-90.0, 220.0, 28.0, 208.0, 40.0), "turned shape, upright text"), + // A text rectangle inside the shape: the shape keeps its text, with wider insets. + drawing_shape("roundRect", (0.0, 0.0, 192.0, 216.0, 72.0), (0.0, 24.0, 204.0, 168.0, 48.0), "inside its shape"), + ]); + let expected = [ + ("wider than its shape", on_slide(0.0, 36.0, 216.0, 72.0)), + ("turned shape, upright text", on_slide(220.0, 28.0, 208.0, 40.0)), + ("inside its shape", on_slide(24.0, 204.0, 168.0, 48.0)), + ]; + + let p = deckcraft_pptx::import(&deck).expect("import"); + assert!(p.validate().is_empty(), "{:?}", p.validate()); + check(&p, &expected); + // The diagram is one group; text that left its shape is in a frameless shape of the group. + let group = p.slides[0].shapes.iter().find(|s| s.name == "Diagram 1").expect("diagram"); + let ShapeKind::Group { children, .. } = &group.kind else { panic!("diagram is not a group") }; + assert_eq!(children.len(), 5); + let inside = children.iter().find(|s| s.text.as_ref().is_some_and(|t| t.text() == "inside its shape")).expect("inside"); + assert!(matches!(inside.geom, deckcraft_model::Geom::Preset { ref name, .. } if name == "roundRect")); + for moved in children.iter().filter(|s| s.text.as_ref().is_some_and(|t| t.text() != "inside its shape")) { + assert_eq!(moved.fill, Some(deckcraft_model::style::Fill::None)); + assert_eq!(moved.line.as_ref().and_then(|l| l.fill.clone()), Some(deckcraft_model::style::Fill::None)); + } + + // The exported deck lays the text out in the same places. + let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("re-import"); + check(&q, &expected); +} diff --git a/crates/pptx/tests/table_styles.rs b/crates/pptx/tests/table_styles.rs index 015543f2..311992df 100644 --- a/crates/pptx/tests/table_styles.rs +++ b/crates/pptx/tests/table_styles.rs @@ -20,7 +20,7 @@ fn style_xml() -> String { /// An exported deck with a 3x2 table using the probe style. fn table_deck() -> Vec { let mut p = defaults::new_presentation(None); - let id = ShapeId(p.alloc_id()); + let id = ShapeId(p.alloc_id().expect("ids")); let first = p.slides[0].id; let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0); t.style = "medium2-accent1".into(); @@ -82,3 +82,16 @@ fn table_styles_the_file_defines_are_read_used_and_kept() { let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save"); assert_eq!(def2.parts, def.parts); } + +#[test] +fn table_style_no_table_uses_is_kept_on_save() { + let mut p = deckcraft_pptx::import(&table_deck()).expect("import"); + assert!(p.table_styles.iter().any(|d| d.id == STYLE_GUID)); + // Drop the only table that uses the style; the deck still defines it. + for s in &mut p.slides { + std::sync::Arc::make_mut(s).shapes.retain(|sh| !matches!(sh.kind, deckcraft_model::ShapeKind::Table(_))); + } + let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport"); + let d = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("style kept"); + assert_eq!(d.name, "Probe Style"); +} diff --git a/crates/pptx/tests/tags.rs b/crates/pptx/tests/tags.rs new file mode 100644 index 00000000..e8903b44 --- /dev/null +++ b/crates/pptx/tests/tags.rs @@ -0,0 +1,38 @@ +//! Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`) on the presentation, slides and shapes +//! survive a save and reopen. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use deckcraft_engine::Session; + +fn t(n: &str, v: &str) -> (String, String) { + (n.to_string(), v.to_string()) +} + +#[test] +fn tags_survive_round_trip() { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let mut p = s.doc().expect("doc").doc.as_ref().clone(); + p.tags = vec![t("THINKCELLPRESENTATIONDONOTDELETE", "")]; + let slide = Arc::make_mut(&mut p.slides[0]); + slide.tags = vec![t("TIMING", "5"), t("SECTION", "Intro")]; + let shape_id = slide.shapes[0].id; + slide.shapes[0].tags = vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]; + + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(Cursor::new(bytes.clone())).expect("zip"); + let mut ct = String::new(); + zip.by_name("[Content_Types].xml").expect("ct").read_to_string(&mut ct).expect("read"); + assert!(ct.contains("presentationml.tags+xml")); + assert_eq!((0..zip.len()).filter(|i| zip.by_index(*i).is_ok_and(|f| f.name().starts_with("ppt/tags/"))).count(), 3); + + let back = deckcraft_pptx::import(&bytes).expect("import"); + assert_eq!(back.tags, p.tags); + assert_eq!(back.slides[0].tags, p.slides[0].tags); + let first = &back.slides[0].shapes[0]; + assert_eq!(first.name, p.slides[0].shapes.iter().find(|s| s.id == shape_id).map(|s| s.name.clone()).unwrap_or_default()); + assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]); + assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty())); +} diff --git a/crates/pptx/tests/transition_xml.rs b/crates/pptx/tests/transition_xml.rs new file mode 100644 index 00000000..ffa5bcf4 --- /dev/null +++ b/crates/pptx/tests/transition_xml.rs @@ -0,0 +1,72 @@ +//! Transition XML as other applications write it, spliced into an exported deck and read back. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::anim::Transition; +use deckcraft_model::defaults; + +const P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"; +const P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/main"; + +/// Exports a one-slide deck, replaces the slide's `mc:AlternateContent` transition block with +/// `xml` and returns the imported transition of that slide. +fn import_with(xml: &str) -> Transition { + let mut p = defaults::new_presentation(None); + std::sync::Arc::make_mut(&mut p.slides[0]).transition = Some(Transition { kind: "fade".into(), duration_ms: 3000, ..Default::default() }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if f.name() == "ppt/slides/slide1.xml" { + let s = String::from_utf8(d).expect("utf8"); + let t = s.find(" String { + format!( + r#"{choice}{fallback}"# + ) +} + +/// PowerPoint 2021/2024 write a 0.5 s Morph with neither `spd` nor `p14:dur` (issue #117). +#[test] +fn powerpoint_half_second_morph_without_spd() { + let xml = mc(P159, "p159", r#""#, ""); + let t = import_with(&xml); + assert_eq!((t.kind.as_str(), t.duration_ms), ("morph", 500)); +} + +#[test] +fn missing_spd_is_fast_for_a_plain_transition() { + let t = import_with(""); + assert_eq!((t.kind.as_str(), t.duration_ms), ("fade", 500)); +} + +#[test] +fn spd_and_dur_still_apply() { + let t = import_with(r#""#); + assert_eq!(t.duration_ms, 750); + let xml = mc( + P159, + "p159", + &format!(r#""#), + r#""#, + ); + let t = import_with(&xml); + assert_eq!((t.kind.as_str(), t.duration_ms), ("morph", 1250)); +} diff --git a/crates/render/src/bevel.rs b/crates/render/src/bevel.rs index 36de1f05..854d3c77 100644 --- a/crates/render/src/bevel.rs +++ b/crates/render/src/bevel.rs @@ -14,11 +14,10 @@ use deckcraft_color::Rgba; use kurbo::{Affine, BezPath, Point, Rect}; use vello_cpu::{Pixmap, RenderContext, peniko}; +use crate::light::Lighting; + /// Longest side of the rasterised lighting, in pixels. const MAX_SIDE: f64 = 512.0; -const AMBIENT: f64 = 0.453; -const DIFFUSE: f64 = 0.731; -const SPECULAR: f64 = 14.7; /// A top bevel: its width and height in points and its preset. #[derive(Clone, Debug, PartialEq)] @@ -26,9 +25,8 @@ pub struct Bevel { pub w: f64, pub h: f64, pub preset: String, - /// Lit as measured (the three-point rig and matte material); other rigs and materials are not - /// drawn. - pub modelled: bool, + /// The light rig and material that light it. + pub lighting: Lighting, } /// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene @@ -39,8 +37,7 @@ pub fn from_raw(raw: &str) -> Option { } // A 3-D shape without a bevel still has its face lit: a bevel of no width. let Some(i) = raw.find("bevelT") else { - let b = Bevel { w: 0.0, h: 0.0, preset: "circle".into(), modelled: false }; - return Some(Bevel { modelled: modelled(raw), ..b }); + return Some(Bevel { w: 0.0, h: 0.0, preset: "circle".into(), lighting: Lighting::from_raw(raw) }); }; let tag = raw.get(i..)?; let tag = tag.get(..tag.find('>')?)?; @@ -53,23 +50,11 @@ pub fn from_raw(raw: &str) -> Option { let emu = |name: &str| attr(name).and_then(|v| v.parse::().ok()).map(|v| v / 12700.0); let w = emu("w").unwrap_or(6.0).clamp(0.0, 2000.0); let h = emu("h").unwrap_or(6.0).clamp(0.0, 2000.0); - (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled: modelled(raw) }) -} - -/// Whether the light rig and material (from anywhere in the XML: `a:lightRig rig=`, -/// `prstMaterial=`) are the ones measured: the three-point rig, matte. -fn modelled(raw: &str) -> bool { - let value_after = |key: &str| -> Option<&str> { - let s = raw.find(key)? + key.len(); - let rest = raw.get(s..)?; - rest.get(..rest.find('"')?) - }; - let rig = value_after("rig=\"").unwrap_or("threePt"); - let material = value_after("prstMaterial=\"").unwrap_or("warmMatte"); - rig == "threePt" && matches!(material, "warmMatte" | "matte") + (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), lighting: Lighting::from_raw(raw) }) } /// The light, pointing from the surface towards it (x right, y down, z towards the viewer). +#[cfg(test)] fn light() -> [f64; 3] { let (az, el) = (240f64.to_radians(), 45f64.to_radians()); [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] @@ -121,19 +106,16 @@ fn nearest_edge(polys: &[Vec], areas: &[f64], p: Point) -> Option<(f64, k /// inside `path` (shape space, laid out in `bounds`), `m` mapping it to the output. #[allow(clippy::too_many_arguments)] pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &BezPath, bounds: Rect, m: Affine, alpha: f64) { - // Other light rigs and materials light the face and band differently (brighter, darker, - // shinier); rather than guess, they stay flat as before. - if !bevel.modelled || bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { + let lit = &bevel.lighting; + if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { return; } // No bevel: only the face, one lit colour (as PowerPoint lights a 3-D shape's front face, // whichever way the camera turns it, within about 10 levels). if bevel.w <= 0.0 { if let Some(c) = fill { - let f = AMBIENT + DIFFUSE * light()[2]; - let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0) as u8; ctx.set_transform(m); - ctx.set_paint(crate::color(Rgba::rgba(ch(c.r), ch(c.g), ch(c.b), c.a), alpha)); + ctx.set_paint(crate::color(lit.shade(c, [0.0, 0.0, 1.0]), alpha)); ctx.fill_path(path); } return; @@ -148,30 +130,29 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &B return; } let areas: Vec = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::() / 2.0).collect(); - let l = light(); - let face = AMBIENT + DIFFUSE * l[2]; + let face = lit.factor([0.0, 0.0, 1.0]); let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 }; let a = alpha.clamp(0.0, 1.0); let mut data = Vec::with_capacity((pw * ph) as usize); for j in 0..ph { for i in 0..pw { let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height()); - let f = match nearest_edge(&polys, &areas, p) { + let n = match nearest_edge(&polys, &areas, p) { Some((d, e)) if d < bevel.w => { let z = slope(&bevel.preset, d / bevel.w) * ratio; let n = [e.x * z, e.y * z, 1.0]; let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - let dot = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len; - AMBIENT + DIFFUSE * dot.max(0.0) + [n[0] / len, n[1] / len, n[2] / len] } - _ => face, + _ => [0.0, 0.0, 1.0], }; + let f = lit.factor(n); let px = match fill { Some(c) => { - let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0); + let lc = lit.shade(c, n); let ca = c.a as f64 * a / 255.0; - let pm = |v: f64| (v * ca).round().clamp(0.0, 255.0) as u8; - vello_cpu::color::PremulRgba8 { r: pm(ch(c.r)), g: pm(ch(c.g)), b: pm(ch(c.b)), a: (ca * 255.0).round() as u8 } + let pm = |v: u8| (f64::from(v) * ca).round().clamp(0.0, 255.0) as u8; + vello_cpu::color::PremulRgba8 { r: pm(lc.r), g: pm(lc.g), b: pm(lc.b), a: (ca * 255.0).round() as u8 } } None => { // A veil: white where the band is lighter than the face, black where darker. @@ -204,17 +185,19 @@ mod tests { #[test] fn bevels_are_read_from_kept_3d_xml() { let raw = r#""#; - assert_eq!(from_raw(raw), Some(Bevel { w: 4.0, h: 1.3, preset: "angle".into(), modelled: true })); + let b = from_raw(raw).unwrap(); + assert_eq!((b.w, b.h, b.preset.as_str()), (4.0, 1.3, "angle")); + assert_eq!(b.lighting, Lighting::from_raw(raw)); // Defaults: 6 pt square, circle. let raw = r#""#; - assert_eq!(from_raw(raw), Some(Bevel { w: 6.0, h: 6.0, preset: "circle".into(), modelled: true })); - // Another rig or material: not modelled. + assert!(from_raw(raw).is_some_and(|b| (b.w, b.h, b.preset.as_str()) == (6.0, 6.0, "circle"))); + // Another rig or material is lit by it. let raw = r#""#; - assert!(from_raw(raw).is_some_and(|b| !b.modelled)); + assert!(from_raw(raw).is_some_and(|b| b.lighting.specular > 0.0)); // No scene: flat in PowerPoint. assert_eq!(from_raw(r#""#), None); // A 3-D shape with no bevel: a bevel of no width (its face is lit). - assert!(from_raw(r#""#).is_some_and(|b| b.w == 0.0 && b.modelled)); + assert!(from_raw(r#""#).is_some_and(|b| b.w == 0.0)); assert_eq!(from_raw(r#""#), None); } diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 83c6f869..af5187c9 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -116,8 +116,25 @@ pub fn from_raw(raw: &str) -> Option { /// Where a point of the shape (relative to its centre) lands. fn project(c: &Camera, p: Point, size: f64) -> Point { + project3(c, p.x, p.y, 0.0, size) +} + +/// Direction `v` (shape space: x right, y down, z to the viewer) turned by camera `c`, without +/// perspective: a surface normal as the camera sees it. +pub fn rotate(c: &Camera, v: [f64; 3]) -> [f64; 3] { let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians()); - let (x1, y1, z1) = (p.x * b.cos(), p.y, -p.x * b.sin()); + let (x, y, z) = (v[0], v[1], v[2]); + let (x1, y1, z1) = (x * b.cos() + z * b.sin(), y, -x * b.sin() + z * b.cos()); + let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos()); + [x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos(), z2] +} + +/// Where a point in the shape's space (relative to its centre; z towards the viewer, so an +/// extrusion runs to negative z) lands. +pub fn project3(c: &Camera, x: f64, y: f64, z: f64, size: f64) -> Point { + let p = Point::new(x, y); + let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians()); + let (x1, y1, z1) = (p.x * b.cos() + z * b.sin(), p.y, -p.x * b.sin() + z * b.cos()); let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos()); let (x3, y3) = (x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos()); if c.dist > 0.0 { diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..ac185780 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, axis_scale}; +use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -29,16 +29,65 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { } } -fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { +pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { match s.and_then(|s| s.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), _ => series_color(ctx, c, i), } } +/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'. +fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba { + match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) { + Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + _ => series_fill(ctx, c, i, Some(s)), + } +} + +/// Fill of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point +/// (not waterfalls, which are never read from a ``). +fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba { + if s.point_fills.iter().any(|(k, _)| *k == pi) { + return point_fill(ctx, c, si, s, pi); + } + if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { + return series_color(ctx, c, pi); + } + series_fill(ctx, c, si, Some(s)) +} + +fn polyline(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); + } + } + p +} + +/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve +/// PowerPoint draws, passing through every point. +pub(crate) fn smooth_path(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + let Some(first) = pts.first() else { return p }; + p.move_to(*first); + for k in 0..pts.len() - 1 { + let p0 = pts[k.saturating_sub(1)]; + let (p1, p2) = (pts[k], pts[k + 1]); + let p3 = pts[(k + 2).min(pts.len() - 1)]; + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + p.curve_to(c1, c2, p2); + } + p +} + /// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the /// palette's. -fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { +pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { match s.line.as_ref().and_then(|l| l.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), _ => series_fill(ctx, c, i, Some(s)), @@ -59,7 +108,18 @@ fn split_label(name: &str) -> String { } } -fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { +pub(crate) fn label( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + text: &str, + r: Rect, + size: f64, + align: Align, + anchor: Anchor, + col: Rgba, + bold: bool, +) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -86,6 +146,17 @@ fn fmt_num(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") } } +/// A scatter or bubble chart's value x axis (min, max, major unit): one scale over every series' +/// x values, from zero when they're all positive. `None` for other charts and when there are no x values. +fn value_x_scale(c: &Chart, max_intervals: usize) -> Option<(f64, f64, f64)> { + if c.series.is_empty() || !c.series.iter().all(|s| matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble)) { + return None; + } + let xs = c.series.iter().flat_map(|s| s.x_values.iter().flatten().copied()).filter(|v| v.is_finite()); + let (lo, hi) = xs.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, b)| (a.min(v), b.max(v)))))?; + Some(axis_scale(lo.min(0.0), hi, None, None, None, max_intervals, false)) +} + /// Where the legend goes for `c:legendPos`, and the plot area left over. /// `size` is the legend block (width, height); the result is its top-left corner and the plot area. /// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred @@ -123,6 +194,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo } pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { + match c.kind { + // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. + ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::BoxWhisker => { + let (view, boxes) = crate::chartex::box_view(c); + draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + } + _ => draw_inner(ctx, rctx, c, m, w, h, None), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { @@ -150,7 +234,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. - let legend_items: Vec<(String, Rgba)> = if pie { + let waterfall = c.kind == ChartType::Waterfall; + let legend_items: Vec<(String, Rgba)> = if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect() + } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { + // A map draws its own colour scale. + vec![] + } else if pie { c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() } else { c.series @@ -224,7 +317,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx, rctx, m, - &fmt_num(*v), + &c.data_label(s, *v), Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), base, Align::Center, @@ -242,14 +335,48 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } + if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { + crate::chartex::draw(ctx, rctx, c, m, area, base, text_col); + return; + } + if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) { + crate::radar::draw(ctx, rctx, c, m, area, base, text_col); + return; + } let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; + // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. + // On a horizontal bar chart the category axis is the left one. + let (left_title, bottom_title) = + if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; + let band_h = base * 1.6; + if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { + let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); + label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); + area.y1 -= band_h + base * 0.3; + } + if let Some(t) = left_title.filter(|t| !t.is_empty()) { + let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); + let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); + let half = area.height() / 2.0; + label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); + area.x0 += band_h + base * 0.3; + } // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; - let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)); + // A logarithmic axis is scaled in exponents of its base. + let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc)); + let (min, max, step) = match log { + Some((_, sc)) => sc, + None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)), + }; + let to_e = |v: f64| match log { + Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0), + None => v, + }; // A combo chart's secondary value axis, on the right (vertical value axes only). let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; let ticks = |(lo, hi, st): (f64, f64, f64)| { @@ -257,16 +384,19 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (0..=n).map(move |k| lo + k as f64 * st) }; // Label gutters as wide as the longest value label. - let label_w = - |sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6; + let primary_ticks: Vec = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); + let label_w = |sec: bool, sc: (f64, f64, f64)| { + let vals: Vec = if sec { ticks(sc).collect() } else { primary_ticks.clone() }; + vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6 + }; let gutter = if horiz { base * 4.5 } else if c.y_hidden { base * 0.5 } else { - label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3) + clamp_soft(label_w(false, (min, max, step)), base * 1.5, area.width() * 0.3) }; - let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0); + let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| clamp_soft(label_w(true, sc), base * 1.5, area.width() * 0.3)).unwrap_or(0.0); let plot = if horiz { Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6) } else { @@ -276,14 +406,41 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); - let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + // Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at + // the plot's edge. + let to_v = |v: f64| { + let v = to_e(v).max(min).min(max); + if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } + }; // A series' value as a position on its own axis. let to_axis = |sec: bool, v: f64| match (sec, second) { - (true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(), + (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(), _ => to_v(v), }; + // Minor gridlines, under the major ones: five to a major unit. + if c.minor_gridlines { + let minor = step / 5.0; + let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); + let mut g = BezPath::new(); + for k in (0..=n).filter(|k| k % 5 != 0) { + // On a logarithmic axis `min`, `max` and `step` are exponents of the base. + let e = min + k as f64 * minor; + let pos = to_v(log.map_or(e, |(b, _)| b.powf(e))); + if horiz { + g.move_to((pos, plot.y0)); + g.line_to((pos, plot.y1)); + } else { + g.move_to((plot.x0, pos)); + g.line_to((plot.x1, pos)); + } + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xF2, 0xF2, 0xF2), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&g); + } // Gridlines and value labels. - for v in ticks((min, max, step)) { + for &v in &primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -344,11 +501,41 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width()); + if let Some(sc) = xscale { + for v in ticks(sc) { + let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width(); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), + base, + Align::Center, + Anchor::Top, + text_col, + false, + ); + } + } // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); - for (i, name) in c.categories.iter().enumerate() { - let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; + for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) { + let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) }; if horiz { label( ctx, @@ -378,15 +565,31 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(0.0f64.clamp(min, max)); + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); let ns = bars.len().max(1); + // Gap width and overlap are fractions of one bar's width: a category's band holds its bars + // (overlapping by `overlap` of a bar between neighbours) plus one gap. let gap = c.gap_width.clamp(0.0, 5.0); - let group = band / (1.0 + gap); let stacked = bk.stacked(); - let bar = if stacked { group } else { group / ns as f64 }; + let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) }; + let n = ns as f64; + let bar = band / (n - (n - 1.0) * ov + gap); + let step = bar * (1.0 - ov); + let group = bar + step * (n - 1.0); + // Error bars need each series' mean and spread: worked out once, not per point. + let stats: Vec = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect(); for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -398,8 +601,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h for (bi, (si, s)) in bars.iter().enumerate() { let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; - let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; - let (a, b) = if stacked { + let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; + let (a, b) = if waterfall { + c.waterfall_bars().get(i).copied().unwrap_or_default() + } else if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); pos_acc += v; @@ -416,10 +621,41 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) } else { Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) + } + .abs(); + // A waterfall colours increases, decreases and totals, and joins each bar's end + // to the next bar's start. + let fill = if waterfall { + let slot = if c.subtotals.contains(&i) { + 2 + } else if b < a { + 1 + } else { + 0 + }; + series_color(rctx, c, slot) + } else { + bar_fill(rctx, c, *si, i, s) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); + ctx.set_paint(color(fill, 1.0)); ctx.fill_rect(&r.abs()); + if waterfall && i + 1 < ncat && !horiz { + let y = to_v(b); + let mut j = BezPath::new(); + j.move_to((r.x1, y)); + j.line_to((r.x1 + (band - bar), y)); + ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&j); + } + if let Some(e) = &s.error_bars { + let (end, mid) = if horiz { (if v >= 0.0 { b } else { a }, r.center().y) } else { (if v >= 0.0 { b } else { a }, r.center().x) }; + if let Some((lo, hi)) = stats.get(bi).and_then(|st| e.range_in(st, i, end)) { + let (p0, p1) = if horiz { (to_v(lo), to_v(hi)) } else { (to_axis(sec, lo), to_axis(sec, hi)) }; + err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap)); + } + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { @@ -443,11 +679,17 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } - // Lines, areas, scatter, radar (drawn as lines): the series that aren't bars. + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + // Lines, areas, scatter: the series that aren't bars. let mut acc = vec![0.0; ncat]; for (si, s) in c.series.iter().enumerate() { let kind = c.series_kind(s); - if kind.is_bar_like() { + if kind.is_bar_like() || kind == ChartType::Stock { continue; } let sec = c.on_secondary(s); @@ -468,26 +710,24 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h }; let x = match kind { ChartType::Scatter | ChartType::Bubble => { - let xs: Vec = s.x_values.iter().flatten().copied().collect(); - let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); - let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (slot(i) + 0.5), }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { continue; } - let mut p = BezPath::new(); - for (k, q) in pts.iter().enumerate() { - if k == 0 { - p.move_to(*q); - } else { - p.line_to(*q); - } - } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; ctx.set_transform(m); if area_kind { let mut a = p.clone(); @@ -511,6 +751,28 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } // Axis line. let mut ax = BezPath::new(); if horiz { @@ -526,12 +788,280 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.stroke_path(&ax); } +/// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than +/// its label font) or a bound is NaN: the upper bound wins. +pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 { + let v = if v < lo { lo } else { v }; + if v > hi { hi } else { v } +} + +/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline). +fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) { + let pad = 4.0; + let shift = n as f64 * (box_h + 2.0); + let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad); + let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad); + let y_top = (plot.y0 + pad + shift).min(y_bottom); + let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)]; + let mut best = corners[0]; + let mut best_d = -1.0; + for (x, y) in corners { + let mut min_d = f64::MAX; + for p in pts { + let dx = if p.x < x { + x - p.x + } else if p.x > x + box_w { + p.x - (x + box_w) + } else { + 0.0 + }; + let dy = if p.y < y { + y - p.y + } else if p.y > y + box_h { + p.y - (y + box_h) + } else { + 0.0 + }; + min_d = min_d.min((dx * dx + dy * dy).sqrt()); + } + if pts.is_empty() { + min_d = 1.0; + } + if min_d > best_d { + best_d = min_d; + best = (x, y); + } + } + best +} + +fn pad_units(v: Option) -> f64 { + v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0) +} + +/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot. +fn forecast_pad(c: &Chart) -> (f64, f64) { + if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) { + return (0.0, 0.0); + } + let mut back = 0.0_f64; + let mut fwd = 0.0_f64; + for s in &c.series { + if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) { + continue; + } + for t in s.trendlines.iter().take(8) { + if t.kind == TrendlineType::MovingAvg { + continue; + } + back = back.max(pad_units(t.backward)); + fwd = fwd.max(pad_units(t.forward)); + } + } + (back, fwd) +} + +const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; + +fn map_xy( + plot: Rect, + band: f64, + slot: &impl Fn(f64) -> f64, + horiz: bool, + scatter: bool, + sx0: f64, + sx1: f64, + sec: bool, + x: f64, + y: f64, + to_axis: &impl Fn(bool, f64) -> f64, +) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let value = to_axis(sec, y); + if !value.is_finite() { + return None; + } + let along = if scatter && sx1 > sx0 { + plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + } else { + let origin = if horiz { plot.y0 } else { plot.x0 }; + origin + band * (slot(x - 1.0) + 0.5) + }; + if !along.is_finite() { + return None; + } + Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) +} + +/// The part of segment `a`–`b` that lies inside `r`. +fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let (mut t0, mut t1) = (0.0, 1.0); + let dx = b.x - a.x; + let dy = b.y - a.y; + let p = [-dx, dx, -dy, dy]; + let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y]; + for i in 0..4 { + let (pi, qi) = (p[i], q[i]); + if pi.abs() < 1e-12 { + if qi < 0.0 { + return None; + } + } else { + let t = qi / pi; + if pi < 0.0 { + if t > t1 { + return None; + } else if t > t0 { + t0 = t; + } + } else if t < t0 { + return None; + } else if t < t1 { + t1 = t; + } + } + } + Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) +} + +fn draw_trendlines( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + plot: Rect, + band: f64, + slot: &impl Fn(f64) -> f64, + xscale: Option<(f64, f64, f64)>, + ncat: usize, + horiz: bool, + base: f64, + text_col: Rgba, + to_axis: &impl Fn(bool, f64) -> f64, +) { + let mut label_n = 0usize; + for s in &c.series { + if s.trendlines.is_empty() { + continue; + } + let kind = c.series_kind(s); + let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble); + let sec = c.on_secondary(s); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..ncat { + let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + // The same x extent the scatter marks use, so the fit sits on the points: the chart's + // value x axis where it has one, else the series' own x values. + let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) { + (lo, hi) + } else if scatter { + let mut lo = f64::MAX; + let mut hi = f64::MIN; + for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); + } + (lo, hi) + } else { + (0.0, 0.0) + }; + for (ti, t) in s.trendlines.iter().take(8).enumerate() { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + let samples = fit.samples(x0, x1); + let mut path = BezPath::new(); + let mut prev: Option = None; + let mut ink_pts = Vec::new(); + let mut any = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = ( + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + ) else { + prev = None; + continue; + }; + let Some((ca, cb)) = clip_seg(a, b, plot) else { + prev = None; + continue; + }; + let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6); + if cont { + path.line_to(cb); + } else { + path.move_to(ca); + path.line_to(cb); + } + prev = Some(cb); + if ink_pts.len() < 64 { + ink_pts.push(ca); + ink_pts.push(cb); + } + any = true; + } + if !any { + continue; + } + ctx.set_transform(m); + let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22)); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&path); + let caption = t.caption(&fit); + if caption.is_empty() { + continue; + } + let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; + let box_h = base * 1.3 * lines as f64 + 2.0; + let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72); + let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h)); + label( + ctx, + rctx, + m, + &caption, + Rect::new(x, y, x + box_w, y + box_h), + (base * 0.9).clamp(8.0, 13.0), + Align::Left, + Anchor::Top, + text_col, + false, + ); + label_n += 1; + } + } +} + #[cfg(test)] mod tests { use super::*; const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0); + #[test] + fn scatter_x_is_a_value_scale_over_all_series() { + let mut c = Chart::sample(ChartType::Scatter); + c.series[0].x_values = vec![Some(0.5), Some(7.2), None]; + assert_eq!(value_x_scale(&c, 8), Some((0.0, 8.0, 1.0))); + assert_eq!(value_x_scale(&Chart::sample(ChartType::Column), 8), None); + } + #[test] fn right_legend_is_a_centred_column_and_narrows_the_plot() { let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false); @@ -561,4 +1091,36 @@ mod tests { assert_eq!(plot, AREA); assert_eq!(o.x, 340.0); } + + #[test] + fn clip_keeps_the_part_inside_the_plot() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped"); + assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6); + assert!((a.y - 25.0).abs() < 1e-6); + assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none()); + let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside"); + assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6); + } + + #[test] + fn caption_keeps_clear_of_a_line_into_the_top_right() { + let plot = Rect::new(0.0, 0.0, 200.0, 100.0); + let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)]; + let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0); + assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}"); + } + + #[test] + fn forecast_reserves_at_most_three_category_slots() { + use deckcraft_model::chart::Trendline; + let mut c = Chart::sample(ChartType::Line); + c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() }); + assert_eq!(forecast_pad(&c), (0.5, 3.0)); + c.series[0].trendlines[0].kind = TrendlineType::MovingAvg; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + c.kind = ChartType::Scatter; + c.series[0].trendlines[0].kind = TrendlineType::Linear; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + } } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs new file mode 100644 index 00000000..e62105e8 --- /dev/null +++ b/crates/render/src/chartex.rs @@ -0,0 +1,573 @@ +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the +//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as +//! the column chart of their bins). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{BoxStats, Chart, ChartType, TreeNode}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_color}; +use crate::color; + +/// Draw `c` inside `area` (the space left by the title and legend). +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + if c.kind == ChartType::RegionMap { + region_tiles(ctx, rctx, c, m, area, base, text_col); + return; + } + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + if vals.iter().all(|v| *v <= 0.0) { + return; + } + let value_label = |v: f64| c.data_label(s, v); + let labels = c.data_labels || c.kind == ChartType::Funnel; + match c.kind { + ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), + ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)), + ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)), + _ if !c.category_levels.is_empty() => tree_sunburst(ctx, rctx, c, m, area, base), + _ => sunburst(ctx, rctx, c, m, area, base, &vals), + } +} + +/// Bars centred on one axis, as wide as their value, widest first as given: category labels on +/// the left, values inside. +#[allow(clippy::too_many_arguments)] +fn funnel( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + text_col: Rgba, + vals: &[f64], + value_label: &dyn Fn(f64) -> String, +) { + let n = vals.len().max(1); + let gutter = c.categories.iter().map(|n| n.chars().count()).max().unwrap_or(0) as f64 * base * 0.55 + base; + let plot = Rect::new(area.x0 + gutter.min(area.width() * 0.4), area.y0, area.x1, area.y1); + let max = vals.iter().copied().fold(0.0, f64::max).max(1e-9); + let band = plot.height() / n as f64; + let cx = plot.center().x; + for (i, v) in vals.iter().enumerate() { + let w = v / max * plot.width(); + let y = plot.y0 + band * i as f64; + let r = Rect::new(cx - w / 2.0, y + band * 0.05, cx + w / 2.0, y + band * 0.95); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); + ctx.fill_rect(&r); + if let Some(name) = c.categories.get(i) { + label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + } + label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + } +} + +/// Mix `a` towards `b` by `t` (0..1). +fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { + let t = t.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8; + Rgba::rgb(f(a.r, b.r), f(a.g, b.g), f(a.b, b.b)) +} + +/// A region map without its geography: the boundaries PowerPoint caches come from an online +/// map service in an undocumented format and DeckCraft ships no map data, so each region is a +/// tile in a grid, coloured on the map's value scale, with the scale below and a note saying +/// the map itself is not drawn. +fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let regions: Vec<(String, Option)> = + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect(); + let note_h = base * 1.4; + let scale_h = base * 2.6; + let grid = Rect::new(area.x0, area.y0, area.x1, (area.y1 - note_h - scale_h).max(area.y0)); + let hi = series_color(rctx, c, 0); + let lo = mix(hi, Rgba::WHITE, 0.85); + let (min, max) = regions.iter().filter_map(|(_, v)| *v).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(v), b.max(v))); + let span = if max > min { max - min } else { 1.0 }; + let n = regions.len(); + if n > 0 && grid.area() > 0.0 { + // Columns so that tiles come out about twice as wide as tall. + let aspect = grid.width() / grid.height().max(1.0); + let cols = ((n as f64 * aspect / 2.0).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols).max(1); + let (tw, th) = (grid.width() / cols as f64, grid.height() / rows as f64); + for (i, (name, v)) in regions.iter().enumerate() { + let (col, row) = (i % cols, i / cols); + let t = Rect::new(grid.x0 + tw * col as f64, grid.y0 + th * row as f64, grid.x0 + tw * (col + 1) as f64, grid.y0 + th * (row + 1) as f64) + .inset(-1.5); + let fill = v.map_or(Rgba::rgb(0xD9, 0xD9, 0xD9), |v| mix(lo, hi, (v - min) / span)); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + let dark = v.is_some_and(|v| (v - min) / span > 0.55); + let mut text = name.clone(); + if c.data_labels + && let Some(v) = v + { + text = format!( + "{text} +{}", + c.data_label(s, *v) + ); + } + let inner = t.inset(-base * 0.25); + if inner.width() > base && inner.height() > base * 0.8 { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + } + } + } + // The colour scale: a gradient bar from the lowest to the highest value. + if min <= max { + let bar_w = (area.width() * 0.5).min(base * 24.0); + let y = grid.y1 + base * 0.6; + let bar = Rect::new(area.center().x - bar_w / 2.0, y, area.center().x + bar_w / 2.0, y + base * 0.8); + let steps = 48; + for k in 0..steps { + let x0 = bar.x0 + bar.width() * k as f64 / steps as f64; + let x1 = bar.x0 + bar.width() * (k + 1) as f64 / steps as f64 + 0.5; + ctx.set_transform(m); + ctx.set_paint(color(mix(lo, hi, k as f64 / (steps - 1) as f64), 1.0)); + ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); + } + let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + } + let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); + label( + ctx, + rctx, + m, + "Map preview unavailable: regions shown as tiles", + note, + base * 0.85, + Align::Center, + Anchor::Middle, + mix(text_col, Rgba::WHITE, 0.4), + false, + ); +} + +/// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side, +/// each row kept while it improves the worst aspect ratio. +pub fn squarify(vals: &[f64], r: Rect) -> Vec { + let total: f64 = vals.iter().sum(); + let mut out = vec![Rect::ZERO; vals.len()]; + if total <= 0.0 || r.area() <= 0.0 { + return out; + } + let scale = r.area() / total; + let mut order: Vec = (0..vals.len()).filter(|&i| vals.get(i).is_some_and(|v| *v > 0.0)).collect(); + order.sort_by(|a, b| vals[*b].total_cmp(&vals[*a])); + let mut free = r; + let mut i = 0; + while i < order.len() { + let short = free.width().min(free.height()); + let worst = |row: &[usize]| { + let areas: Vec = row.iter().map(|&k| vals[k] * scale).collect(); + let sum: f64 = areas.iter().sum(); + let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min)); + ((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn)) + }; + let mut end = i + 1; + while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) { + end += 1; + } + let row = &order[i..end]; + let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum(); + let thick = sum / short; + let mut at = 0.0; + for &k in row { + let len = vals[k] * scale / thick; + out[k] = if free.width() >= free.height() { + Rect::new(free.x0, free.y0 + at, free.x0 + thick, free.y0 + at + len) + } else { + Rect::new(free.x0 + at, free.y0, free.x0 + at + len, free.y0 + thick) + }; + at += len; + } + free = if free.width() >= free.height() { + Rect::new(free.x0 + thick, free.y0, free.x1, free.y1) + } else { + Rect::new(free.x0, free.y0 + thick, free.x1, free.y1) + }; + i = end; + } + out +} + +#[allow(clippy::too_many_arguments)] +fn treemap( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + vals: &[f64], + value_label: Option<&dyn Fn(f64) -> String>, +) { + for (i, t) in squarify(vals, area).into_iter().enumerate() { + if t.area() <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = c.categories.get(i).cloned().unwrap_or_default(); + if let (Some(f), Some(v)) = (value_label, vals.get(i)) { + text = format!("{text}\n{}", f(*v)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + } +} + +/// One ring of the innermost level, with the centre left open. +fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { + let total: f64 = vals.iter().sum(); + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.35; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + if sweep <= 0.0 { + continue; + } + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + hole * a0.cos(), cen.y + hole * a0.sin())); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0).append_iter(0.1)); + let a1 = a0 + sweep; + p.line_to(Point::new(cen.x + hole * a1.cos(), cen.y + hole * a1.sin())); + p.extend(kurbo::Arc::new(cen, (hole, hole), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i).filter(|_| sweep > 0.25) { + let mid = a0 + sweep / 2.0; + let rr = (r + hole) / 2.0; + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + label( + ctx, + rctx, + m, + name, + Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), + base, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + a0 = a1; + } +} + +/// `col` mixed toward white by `k` (0..1): deeper hierarchy levels are lighter. +fn lighten(col: Rgba, k: f64) -> Rgba { + let k = k.clamp(0.0, 0.8); + let f = |v: u8| (f64::from(v) + (255.0 - f64::from(v)) * k).round() as u8; + Rgba::rgb(f(col.r), f(col.g), f(col.b)) +} + +/// A multi-level treemap: top-level groups squarified by their totals, each with its label in +/// a banner along the top, and their children squarified inside, down to the leaves. A group +/// and everything in it take the group's colour. +#[allow(clippy::too_many_arguments)] +fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) { + let tree = c.category_tree(); + #[allow(clippy::too_many_arguments)] + fn place( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + r: Rect, + base: f64, + col: &dyn Fn(usize) -> Rgba, + value_label: Option<&dyn Fn(f64) -> String>, + depth: usize, + ) { + let vals: Vec = nodes.iter().map(|n| n.value).collect(); + for (i, (n, t)) in nodes.iter().zip(squarify(&vals, r)).enumerate() { + if t.area() <= 0.0 { + continue; + } + let fill = col(i); + if n.children.is_empty() { + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = n.name.clone(); + if let Some(f) = value_label { + text = format!("{text}\n{}", f(n.value)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + continue; + } + // A group: its banner, then its children below it. + let banner = (base * 1.5).min(t.height() * 0.3); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + if t.width() > base * 2.0 && banner > base * 0.8 { + let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); + label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + } + let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); + let k = lighten(fill, 0.12 * (depth + 1) as f64); + place(ctx, rctx, m, &n.children, inner, base, &|_| k, value_label, depth + 1); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.0)); + ctx.stroke_path(&t.to_path(0.1)); + } + } + place(ctx, rctx, m, &tree, area, base, &|i| series_color(rctx, c, i), value_label, 0); +} + +/// A multi-level sunburst: the top level as the inner ring, each deeper level in the next ring +/// out within its parent's angle, coloured by top-level group and lighter further out. +fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) { + let tree = c.category_tree(); + fn depth(n: &[TreeNode]) -> usize { + n.iter().map(|c| 1 + depth(&c.children)).max().unwrap_or(0) + } + let levels = depth(&tree).max(1); + let total: f64 = tree.iter().map(|n| n.value).sum(); + if total <= 0.0 { + return; + } + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.2; + let ring = (r - hole) / levels as f64; + let cen = area.center(); + #[allow(clippy::too_many_arguments)] + fn ring_of( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + a0: f64, + span: f64, + d: usize, + geo: (Point, f64, f64, f64), + base: f64, + col: &dyn Fn(usize) -> Rgba, + ) { + let (cen, hole, ring, total) = geo; + let sum: f64 = nodes.iter().map(|n| n.value).sum(); + if sum <= 0.0 { + return; + } + let (ri, ro) = (hole + ring * d as f64, hole + ring * (d + 1) as f64); + let mut a = a0; + for (i, n) in nodes.iter().enumerate() { + let sweep = n.value / sum * span; + if sweep <= 0.0 { + continue; + } + let fill = col(i); + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + ri * a.cos(), cen.y + ri * a.sin())); + p.line_to(Point::new(cen.x + ro * a.cos(), cen.y + ro * a.sin())); + p.extend(kurbo::Arc::new(cen, (ro, ro), a, sweep, 0.0).append_iter(0.1)); + let a1 = a + sweep; + p.line_to(Point::new(cen.x + ri * a1.cos(), cen.y + ri * a1.sin())); + p.extend(kurbo::Arc::new(cen, (ri, ri), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + // Labels where the segment has room for them. + let mid = a + sweep / 2.0; + let rr = (ri + ro) / 2.0; + if sweep * rr > base * 2.5 && !n.name.is_empty() { + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + let half = (ring * 0.48).max(base); + label( + ctx, + rctx, + m, + &n.name, + Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), + base * 0.9, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + if !n.children.is_empty() { + let k = lighten(fill, 0.15); + ring_of(ctx, rctx, m, &n.children, a, sweep, d + 1, (cen, hole, ring, total), base, &|_| k); + } + a = a1; + } + } + ring_of(ctx, rctx, m, &tree, -std::f64::consts::FRAC_PI_2, std::f64::consts::TAU, 0, (cen, hole, ring, total), base, &|i| { + series_color(rctx, c, i) + }); +} + +/// A box chart's summaries: per series, per group. +pub struct Boxes { + pub stats: Vec>>, +} + +/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one +/// category per group, legend by series), and the boxes to draw in it. The frame's values are +/// each group's extremes, so the value axis covers every whisker and outlier. +pub fn box_view(c: &Chart) -> (Chart, Boxes) { + let (groups, stats) = c.box_groups(); + let mut v = c.clone(); + v.kind = ChartType::Column; + v.categories = groups; + v.raw = None; + let ext = |b: &BoxStats, hi: bool| { + let o = b.outliers.iter().copied(); + if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) } + }; + let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min); + for (s, st) in v.series.iter_mut().zip(&stats) { + s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect(); + } + if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() { + // The frame's values are the highs; below zero the axis must reach the lowest point too. + let mag = 10f64.powf(lowest.abs().log10().floor()); + v.y_min = Some((lowest / mag).floor() * mag); + } + (v, Boxes { stats }) +} + +/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers +/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each +/// group's band. +#[allow(clippy::too_many_arguments)] +pub fn draw_boxes( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + b: &Boxes, + m: Affine, + plot: Rect, + band: f64, + horiz: bool, + to_v: &dyn Fn(f64) -> f64, +) { + let ns = b.stats.len().max(1); + let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0)); + let w = group / ns as f64; + let line = Rgba::rgb(0x40, 0x40, 0x40); + for (si, per) in b.stats.iter().enumerate() { + let fill = series_color(rctx, c, si); + for (gi, st) in per.iter().enumerate() { + let Some(st) = st else { continue }; + let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64; + let (a, z) = (c0 + w * 0.1, c0 + w * 0.9); + let mid = (a + z) / 2.0; + // Along the category axis `a..z`, along the value axis `to_v`. + let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) }; + let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) }; + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + let bx = rect(a, z, to_v(st.q1), to_v(st.q3)); + ctx.fill_rect(&bx); + let mut p = BezPath::new(); + // Whiskers with caps, and the median. + for (from, to) in [(st.q3, st.high), (st.q1, st.low)] { + p.move_to(pt(mid, to_v(from))); + p.line_to(pt(mid, to_v(to))); + p.move_to(pt(mid - w * 0.2, to_v(to))); + p.line_to(pt(mid + w * 0.2, to_v(to))); + } + p.move_to(pt(a, to_v(st.median))); + p.line_to(pt(z, to_v(st.median))); + ctx.set_paint(color(line, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.25)); + ctx.stroke_path(&p); + ctx.stroke_path(&bx.to_path(0.1)); + // The mean as an x. + let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0)); + let mut x = BezPath::new(); + x.move_to((mx.x - r, mx.y - r)); + x.line_to((mx.x + r, mx.y + r)); + x.move_to((mx.x - r, mx.y + r)); + x.line_to((mx.x + r, mx.y - r)); + ctx.stroke_path(&x); + for o in &st.outliers { + let q = pt(mid, to_v(*o)); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1)); + ctx.set_paint(color(line, 1.0)); + ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squarify_tiles_the_area_in_proportion() { + let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0]; + let r = Rect::new(0.0, 0.0, 600.0, 400.0); + let tiles = squarify(&vals, r); + let total: f64 = vals.iter().sum(); + for (t, v) in tiles.iter().zip(vals) { + assert!((t.area() - v / total * r.area()).abs() < 1e-6, "{t:?} for {v}"); + assert!(t.x0 >= -1e-9 && t.y0 >= -1e-9 && t.x1 <= 600.0 + 1e-6 && t.y1 <= 400.0 + 1e-6, "{t:?}"); + // Squarified: no sliver tiles. + let aspect = t.width().max(t.height()) / t.width().min(t.height()); + assert!(aspect < 4.0, "{t:?}"); + } + for (i, a) in tiles.iter().enumerate() { + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 1e-6, "{a:?} overlaps {b:?}"); + } + } + assert!(squarify(&[0.0, 0.0], r).iter().all(|t| t.area() == 0.0)); + } + + #[test] + fn waterfall_bars_float_and_totals_stand() { + let c = Chart { + kind: ChartType::Waterfall, + subtotals: vec![3], + series: vec![deckcraft_model::chart::Series { values: vec![Some(100.0), Some(40.0), Some(-25.0), Some(115.0)], ..Default::default() }], + ..Default::default() + }; + assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]); + assert_eq!(c.value_range(), (0.0, 140.0)); + } +} diff --git a/crates/render/src/extrude.rs b/crates/render/src/extrude.rs new file mode 100644 index 00000000..08369f48 --- /dev/null +++ b/crates/render/src/extrude.rs @@ -0,0 +1,199 @@ +//! 3-D extrusion (`a:sp3d extrusionH`, `a:extrusionClr`) seen through a turned camera, drawn in +//! 2-D: quads join the shape's outline on its front face to the same outline on its back face (the +//! camera's view of the depth axis), and the face is drawn on top as usual. +//! +//! Each side (one quad per flattened outline edge) is lit for its own normal by the shape's light +//! rig and material, and the sides are drawn farthest first. Without a turned camera an extrusion +//! points at the viewer and nothing of it shows. + +use deckcraft_color::{Rgba, SchemeSlot}; +use deckcraft_model::ColorRef; +use deckcraft_model::resolve::Ctx; +use kurbo::{Affine, BezPath, Vec2}; +use vello_cpu::RenderContext; + +use crate::camera::{self, Camera}; +use crate::light::Lighting; + +/// Outlines with more edges than this (after flattening) are not extruded. +const MAX_EDGES: usize = 4096; + +/// An extrusion: its depth in points and its own colour, if set. +#[derive(Clone, Debug, PartialEq)] +pub struct Extrusion { + pub depth: f64, + pub color: Option, +} + +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) +} + +/// The extrusion in 3-D XML kept from the file (`a:sp3d extrusionH="…"` with an `a:scene3d`). +pub fn from_raw(raw: &str) -> Option { + if !raw.contains("scene3d") { + return None; + } + let i = raw.find("sp3d ")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let depth = attr(tag, "extrusionH")?.parse::().ok()? / 12700.0; + if !(depth.is_finite() && depth > 0.01) { + return None; + } + let color = color_in(raw, "extrusionClr"); + Some(Extrusion { depth: depth.min(4000.0), color }) +} + +/// The colour in the first `` element of `raw` (an `srgbClr` or a `schemeClr`). +fn color_in(raw: &str, name: &str) -> Option { + let j = raw.find(&format!("{name}>"))?; + let body = raw.get(j..)?; + let end = body.find(&format!("/a:{name}")).or_else(|| body.find(&format!("/{name}"))).unwrap_or(body.len()); + let body = body.get(..end)?; + if let Some(k) = body.find("srgbClr ") { + let v = attr(body.get(k..)?, "val")?; + let n = u32::from_str_radix(v, 16).ok()?; + Some(ColorRef::rgb(Rgba::rgb((n >> 16) as u8, (n >> 8) as u8, n as u8))) + } else if let Some(k) = body.find("schemeClr ") { + SchemeSlot::from_xml(attr(body.get(k..)?, "val")?).map(ColorRef::scheme) + } else { + None + } +} + +/// A 3-D contour: a band of `width` points around the shape's outline (`a:sp3d contourW`, +/// `a:contourClr`), drawn behind the face so only its outer half shows past the outline. +#[derive(Clone, Debug, PartialEq)] +pub struct Contour { + pub width: f64, + pub color: Option, +} + +/// The contour in 3-D XML kept from the file, if it has one. +pub fn contour_from_raw(raw: &str) -> Option { + let i = raw.find("sp3d ")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let width = attr(tag, "contourW")?.parse::().ok()? / 12700.0; + if !(width.is_finite() && width > 0.01) { + return None; + } + Some(Contour { width: width.min(2000.0), color: color_in(raw, "contourClr") }) +} + +/// Draw `c` around `outline` (shape space, mapped by `m`), lit as a surface facing the viewer. +pub fn draw_contour(ctx: &mut RenderContext, outline: &BezPath, m: Affine, width: f64, color: Rgba, lit: &Lighting) { + ctx.set_transform(m); + ctx.set_paint(crate::color(lit.shade(color, [0.0, 0.0, 1.0]), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.0 * width).with_join(kurbo::Join::Round)); + ctx.stroke_path(outline); +} + +/// The camera's view of the depth axis: where the back face sits relative to the front face, in +/// the turned shape's space (the space `camera::affine` maps into). +pub fn back_offset(c: &Camera, depth: f64, w: f64, h: f64) -> Vec2 { + let size = w.max(h); + camera::project3(c, 0.0, 0.0, -depth, size) - camera::project3(c, 0.0, 0.0, 0.0, size) +} + +/// The side colour before lighting: the extrusion colour, or the face's. +pub fn side_color(rctx: &Ctx, e: &Extrusion, face: Option) -> Rgba { + e.color.as_ref().map(|c| rctx.color(c, face)).or(face).unwrap_or(Rgba::rgb(0x80, 0x80, 0x80)) +} + +/// Draw the sides of `outline` (shape space, `w` × `h`): one quad per edge between the front face +/// and the back face, each lit by `lit` for its own normal as camera `cam` sees it, farthest first. +/// `pre` maps the turned shape's space to the output and `turn` is the camera's turn. +#[allow(clippy::too_many_arguments)] +pub fn draw( + ctx: &mut RenderContext, + outline: &BezPath, + w: f64, + h: f64, + cam: &Camera, + depth: f64, + pre: Affine, + turn: Affine, + side: Rgba, + lit: &Lighting, +) { + let offset = back_offset(cam, depth, w, h); + let px = (pre * offset.to_point() - pre * kurbo::Point::ZERO).hypot(); + if !px.is_finite() || px < 0.25 { + return; + } + let polys = crate::pathgrad::polygons(outline); + let size = w.max(h); + let mut quads: Vec<(f64, BezPath, Rgba)> = vec![]; + for poly in &polys { + if poly.len() > MAX_EDGES { + continue; + } + let area: f64 = poly.windows(2).map(|q| q[0].x * q[1].y - q[1].x * q[0].y).sum::() / 2.0; + for e in poly.windows(2) { + let (a, b) = (e[0], e[1]); + let d = b - a; + let len = d.hypot(); + if len < 1e-9 { + continue; + } + // Outward normal in the shape's plane (as in the bevel), turned by the camera. + let n = kurbo::Vec2::new(d.y, -d.x) / len; + let n = if area > 0.0 { n } else { -n }; + let vn = camera::rotate(cam, [n.x, n.y, 0.0]); + if vn[2] < -0.02 { + continue; + } + let mid = a.midpoint(b); + let z = camera::rotate(cam, [mid.x - w / 2.0, mid.y - h / 2.0, -depth / 2.0])[2]; + let (fa, fb) = (turn * a, turn * b); + let mut q = BezPath::new(); + q.move_to(fa); + q.line_to(fb); + q.line_to(fb + offset); + q.line_to(fa + offset); + q.close_path(); + quads.push((z, q, lit.shade(side, vn))); + } + } + let _ = size; + quads.sort_by(|a, b| a.0.total_cmp(&b.0)); + ctx.set_transform(pre); + for (_, q, c) in &quads { + ctx.set_paint(crate::color(*c, 1.0)); + ctx.fill_path(q); + // Close the hairline seams between neighbouring quads. + ctx.set_stroke(kurbo::Stroke::new(0.6 / crate::scale_of(pre).max(1e-6))); + ctx.stroke_path(q); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_depth_and_colour() { + let raw = r#""#; + let e = from_raw(raw).unwrap(); + assert!((e.depth - 30.0).abs() < 1e-9); + assert_eq!(e.color, Some(ColorRef::rgb(Rgba::rgb(0xC0, 0, 0)))); + assert!(from_raw(r#""#).is_none(), "no scene: drawn flat"); + assert!(from_raw(r#""#).is_none()); + } + + #[test] + fn the_back_face_goes_away_from_the_viewer() { + // Turned left about the vertical axis, the back face shows to the right... of the turned + // face for a positive longitude, and nothing for a front camera. + let c = Camera { lat: 0.0, lon: 30.0, rev: 0.0, dist: 0.0 }; + let o = back_offset(&c, 20.0, 100.0, 50.0); + assert!((o.x.abs() - 10.0).abs() < 1e-9 && o.y.abs() < 1e-9, "{o:?}"); + let front = Camera { lat: 0.0, lon: 0.0, rev: 0.0, dist: 0.0 }; + assert!(back_offset(&front, 20.0, 100.0, 50.0).hypot() < 1e-9); + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f7d56c5d..9bd6c421 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -1,7 +1,7 @@ //! Decoded pictures shared by every render on this process, keyed by the media bytes' address and //! the requested size level and adjustments, so each picture is decoded once per size. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; use deckcraft_model::style::PictureAdjust; @@ -9,6 +9,17 @@ use vello_cpu::Pixmap; const BUDGET: usize = 512 << 20; +/// Largest straight-RGBA picture a decode may produce (bytes, 4 per pixel). Checked from the +/// header before decoding and again before the conversion to RGBA, which the decoder's own +/// allocation limit doesn't cover (a grey image converts to four times its decoded size). +const MAX_RGBA: u64 = 512 << 20; + +/// Pictures that failed to decode, remembered so they aren't retried every frame: at most this +/// many, holding at most this many encoded bytes (oldest forgotten first). Larger failed inputs +/// aren't kept at all. +const BAD_MAX: usize = 256; +const BAD_BUDGET: usize = 64 << 20; + struct Entry { _bytes: Arc>, pm: Arc, @@ -20,7 +31,11 @@ struct Cache { map: HashMap<(usize, u32, u64), Entry>, bytes: usize, clock: u64, + /// Failed sources by address; each holds its bytes so the address can't be reused while it + /// is listed. `bad_order` is oldest first; `bad_bytes` their encoded size. bad: HashMap>>, + bad_order: VecDeque, + bad_bytes: usize, /// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them. anims: HashMap<(usize, u64), (Arc>, Option>)>, } @@ -115,9 +130,47 @@ fn decode_at(bytes: &[u8], side: u32) -> Option { if crate::metafile::is_metafile(bytes) { return crate::metafile::rasterize(bytes, side); } + decode_raster(bytes, MAX_RGBA) +} + +impl Cache { + /// Remember that `bytes` doesn't decode, within [`BAD_MAX`] and [`BAD_BUDGET`]. + fn mark_bad(&mut self, addr: usize, bytes: &Arc>) { + if bytes.len() > BAD_BUDGET || self.bad.contains_key(&addr) { + return; + } + while self.bad.len() >= BAD_MAX || self.bad_bytes + bytes.len() > BAD_BUDGET { + let Some(old) = self.bad_order.pop_front() else { break }; + if let Some(b) = self.bad.remove(&old) { + self.bad_bytes = self.bad_bytes.saturating_sub(b.len()); + } + } + self.bad.insert(addr, bytes.clone()); + self.bad_order.push_back(addr); + self.bad_bytes += bytes.len(); + } + + fn is_bad(&self, addr: usize, bytes: &Arc>) -> bool { + self.bad.get(&addr).is_some_and(|b| Arc::ptr_eq(b, bytes)) + } +} + +/// Whether a `w` × `h` picture fits [`MAX_RGBA`] (`max`: the cap in bytes). +fn fits(w: u32, h: u32, max: u64) -> bool { + w > 0 && h > 0 && (w as u64) * (h as u64) * 4 <= max +} + +/// Decode a raster picture to RGBA, refusing (before decoding, by its header, and again before +/// the RGBA conversion) any whose RGBA would exceed `max` bytes. +fn decode_raster(bytes: &[u8], max: u64) -> Option { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + if let Ok((w, h)) = reader.into_dimensions() + && !fits(w, h, max) + { + return None; + } let img = image::load_from_memory(bytes).ok()?; - let (w, h) = (img.width(), img.height()); - if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 { + if !fits(img.width(), img.height(), max) { return None; } Some(img.to_rgba8()) @@ -173,10 +226,101 @@ pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> { None } +/// A copy of a PNG with a `pHYs` chunk saying `dpi` dots per inch. +/// +/// Anything that is not a PNG, or a resolution the reader would reject, is returned unchanged. +/// A file that already has `pHYs` is left alone. +pub(crate) fn stamp_png_dpi(png: &[u8], dpi: f64) -> Vec { + let Some(ppm) = pixels_per_metre(dpi) else { return png.to_vec() }; + let Some(end) = ihdr_end(png) else { return png.to_vec() }; + if has_phys(png) { + return png.to_vec(); + } + let mut body = Vec::with_capacity(13); + body.extend_from_slice(b"pHYs"); + body.extend_from_slice(&ppm.to_be_bytes()); + body.extend_from_slice(&ppm.to_be_bytes()); + body.push(1); + let crc = png_crc(&body); + let mut out = Vec::with_capacity(png.len() + 21); + out.extend_from_slice(png.get(..end).unwrap_or(png)); + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&crc.to_be_bytes()); + if let Some(rest) = png.get(end..) { + out.extend_from_slice(rest); + } + out +} + +/// Dots per inch as pixels per metre, the unit a PNG `pHYs` chunk stores. +fn pixels_per_metre(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=10_000.0).contains(&dpi) { + return None; + } + let ppm = (dpi / 0.0254).round(); + if !ppm.is_finite() || !(1.0..=u32::MAX as f64).contains(&ppm) { + return None; + } + Some(ppm as u32) +} + +fn ihdr_end(png: &[u8]) -> Option { + if !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return None; + } + let len = be_u32(png, 8)? as usize; + if png.get(12..16) != Some(b"IHDR".as_slice()) { + return None; + } + let end = 8usize.checked_add(12)?.checked_add(len)?; + (end <= png.len()).then_some(end) +} + +fn has_phys(png: &[u8]) -> bool { + let mut i = 8usize; + for _ in 0..64 { + let len = match be_u32(png, i) { + Some(n) => n as usize, + None => return false, + }; + let Some(kind) = png.get(i + 4..i + 8) else { return false }; + if kind == b"pHYs" { + return true; + } + if kind == b"IDAT" || kind == b"IEND" { + return false; + } + i = match i.checked_add(12).and_then(|n| n.checked_add(len)) { + Some(n) if n <= png.len() => n, + _ => return false, + }; + } + false +} + +fn be_u32(bytes: &[u8], i: usize) -> Option { + let s = bytes.get(i..i + 4)?; + let s: [u8; 4] = s.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +fn png_crc(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + let mask = 0u32.wrapping_sub(c & 1); + c = (c >> 1) ^ (0xEDB8_8320 & mask); + } + } + !c +} + /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Option> { let addr = Arc::as_ptr(bytes) as usize; - if with(|c| c.bad.contains_key(&addr)) { + if with(|c| c.is_bad(addr, bytes)) { return None; } // Power-of-two size buckets so zooming doesn't re-decode every frame. @@ -194,9 +338,7 @@ pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Optio } let target = 1u32 << bucket; let Some(mut img) = decode_at(bytes, target) else { - with(|c| { - c.bad.insert(addr, bytes.clone()); - }); + with(|c| c.mark_bad(addr, bytes)); return None; }; let (w, h) = img.dimensions(); @@ -376,6 +518,66 @@ mod tests { use super::*; + fn grey_png(w: u32, h: u32) -> Vec { + let img = image::GrayImage::from_pixel(w, h, image::Luma([90])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).expect("encode"); + out.into_inner() + } + + #[test] + fn rgba_conversion_is_refused_past_the_cap() { + let png = grey_png(100, 50); + let rgba = 100 * 50 * 4; + assert!(decode_raster(&png, rgba - 1).is_none(), "grey decodes small, its RGBA does not fit"); + let img = decode_raster(&png, rgba).expect("fits exactly"); + assert_eq!(img.dimensions(), (100, 50)); + // A picture whose header claims a huge size is refused. + let mut huge = png.clone(); + huge[16..20].copy_from_slice(&60_000u32.to_be_bytes()); + huge[20..24].copy_from_slice(&60_000u32.to_be_bytes()); + assert!(decode_raster(&huge, MAX_RGBA).is_none()); + assert!(!fits(60_000, 60_000, MAX_RGBA) && fits(8192, 8192, MAX_RGBA)); + } + + #[test] + fn failed_sources_are_bounded_and_released() { + let mut c = Cache::default(); + let srcs: Vec>> = (0..BAD_MAX + 50).map(|i| Arc::new(vec![i as u8; 1000])).collect(); + for s in &srcs { + c.mark_bad(Arc::as_ptr(s) as usize, s); + } + assert_eq!(c.bad.len(), BAD_MAX); + assert_eq!(c.bad_bytes, BAD_MAX * 1000); + // The oldest were forgotten and their bytes released; the newest are still known. + assert_eq!(Arc::strong_count(&srcs[0]), 1); + assert!(!c.is_bad(Arc::as_ptr(&srcs[0]) as usize, &srcs[0])); + let last = srcs.last().expect("source"); + assert!(c.is_bad(Arc::as_ptr(last) as usize, last)); + // The byte budget holds too, and an input larger than it isn't kept at all. + let big = Arc::new(vec![0u8; BAD_BUDGET / 2 + 1]); + c.mark_bad(Arc::as_ptr(&big) as usize, &big); + let big2 = Arc::new(vec![1u8; BAD_BUDGET / 2 + 1]); + c.mark_bad(Arc::as_ptr(&big2) as usize, &big2); + assert!(c.bad_bytes <= BAD_BUDGET); + assert_eq!(Arc::strong_count(&big), 1); + let huge = Arc::new(vec![0u8; BAD_BUDGET + 1]); + c.mark_bad(Arc::as_ptr(&huge) as usize, &huge); + assert_eq!(Arc::strong_count(&huge), 1); + } + + #[test] + fn invalid_pictures_fail_and_valid_ones_still_decode_and_cache() { + let bad = Arc::new(b"not a picture at all".to_vec()); + assert!(decode_for(&bad, 64.0, &PictureAdjust::default()).is_none()); + assert!(decode_for(&bad, 64.0, &PictureAdjust::default()).is_none()); + let good = Arc::new(grey_png(20, 10)); + let a = decode_for(&good, 64.0, &PictureAdjust::default()).expect("decodes"); + let b = decode_for(&good, 64.0, &PictureAdjust::default()).expect("cached"); + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!((a.width(), a.height()), (20, 10)); + } + #[test] fn duotone_maps_luminance_between_its_two_colours() { use deckcraft_color::Rgba; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..926bb762 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,7 +10,10 @@ mod bevel; mod camera; mod chart; +mod chartex; mod colr; +mod extrude; +mod light; mod compound; mod extent; @@ -23,6 +26,7 @@ mod pattern; mod pathgrad; mod placed; +mod radar; mod ramp; mod table; mod transition; @@ -49,7 +53,9 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; -pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; +pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline}; +pub use table::cell_body as table_cell_body; +pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; pub use transition::{SlideParts, composite_transition, render_transition}; @@ -78,6 +84,19 @@ impl Image { } out } + /// One pixel as straight (unpremultiplied) RGBA; `None` outside the image. + pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?; + let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?; + if a == 0 || a == 255 { + return Some([r, g, b, a]); + } + let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8; + Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a]) + } pub fn to_png(&self) -> Vec { let mut px = self.to_straight(); px.resize(self.width as usize * self.height as usize * 4, 0); @@ -89,12 +108,30 @@ impl Image { } buf } + + /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. + /// + /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is + /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a + /// Retina (2×) export opens at twice the slide's size. + pub fn to_png_at(&self, dpi: f64) -> Vec { + images::stamp_png_dpi(&self.to_png(), dpi) + } + pub fn to_jpeg(&self, quality: u8) -> Vec { + self.to_jpeg_at(quality, None) + } + + /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. + pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { let straight = self.to_straight(); let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; let mut buf = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + if let Some(d) = dpi.and_then(dpi_u16) { + enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); + } if let Err(e) = img.write_with_encoder(enc) { log::error!("JPEG encode: {e}"); return Vec::new(); @@ -111,6 +148,29 @@ impl Image { } } +/// Dots per inch for a slide drawn at `pixels_per_point`. +/// +/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: +/// the same pixels, at the slide's real size, on a Retina display. +pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return 72.0; + } + (pixels_per_point * 72.0).clamp(1.0, 10_000.0) +} + +fn dpi_u16(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { + return None; + } + Some(dpi.round() as u16) +} + +/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. +pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { + images::dpi(bytes) +} + /// Animation state of one shape during a slide show or preview. #[derive(Clone, Debug)] pub struct ShapeState { @@ -249,14 +309,35 @@ pub struct Renderer { /// The fill of the group being drawn, with the transform and box it is laid out in: children /// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group. group_fill: Option<(Fill, Affine, Rect)>, + /// Their aligned outlines, kept from frame to frame. + morphs: morph_path::Cache, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY, group_fill: None } + Renderer { + resources: Resources::new(), + paths: Vec::new(), + bg: None, + drawn: None, + view: Affine::IDENTITY, + group_fill: None, + morphs: morph_path::Cache::default(), + } } } +static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn note_panic() { + PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. +pub fn panics_caught() -> u64 { + PANICS.load(std::sync::atomic::Ordering::Relaxed) +} + thread_local! { static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); } @@ -330,6 +411,7 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) } Err(_) => { log::error!("rendering slide {index} panicked; showing a blank slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) @@ -354,6 +436,7 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt Ok(img) => img, Err(_) => { log::error!("rendering part of slide {index} panicked; showing nothing"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } @@ -385,6 +468,7 @@ pub fn render_blend( Ok(img) => img, Err(_) => { log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); render_slide(pres, new, opts) } @@ -397,6 +481,7 @@ pub fn render_layout(pres: &Presentation, master: usize, layout: Option, Ok(img) => img, Err(_) => { log::error!("rendering a layout panicked; showing a blank image"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); Image::default() } @@ -658,6 +743,7 @@ impl Renderer { } } self.paths.clear(); + self.morphs.sweep(); self.finish(ctx, w, h) } @@ -844,24 +930,46 @@ impl Renderer { } ShapeKind::Ink { strokes } => { for sk in strokes { - let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, _) in &sk.points { + let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); } - let mut bp = BezPath::new(); - for (i, (px, py, _)) in sk.points.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; } let (base, rel) = self.line_space(parent, depth); - bp.apply_affine(rel); ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); + // Uniform-width and highlighter strokes are one path: segments drawn one by one + // overlap at the joins, which darkens see-through highlighter ink in beads. + if legacy || sk.highlighter { + let mut bp = BezPath::new(); + for (i, (px, py, _)) in pts.iter().enumerate() { + if i == 0 { + bp.move_to((*px, *py)); + } else { + bp.line_to((*px, *py)); + } + } + bp.apply_affine(rel); + ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + continue; + } + for pair in pts.windows(2) { + let [p0, p1] = pair else { continue }; + let mut bp = BezPath::new(); + bp.move_to((p0.0, p0.1)); + bp.line_to((p1.0, p1.1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); + let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } } } _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), @@ -885,7 +993,7 @@ impl Renderer { depth: usize, ) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => morph_path::geometry(pm, s, w, h), + Some(pm) => self.morphs.geometry(pm, s, w, h), None => (shape_geometry(s, w, h), Vec::new()), }; let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); @@ -926,6 +1034,32 @@ impl Renderer { ctx.push_layer(None, None, None, None, None); let _ = r; } + // A 3-D extrusion under a turned camera: the sides, swept behind the face. + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(ex) = extrude::from_raw(raw) + && let Some(cam) = camera::from_raw(raw) + && let Some(turn) = camera::affine(&cam, w, h) + && turn.determinant().abs() > 1e-9 + { + let face = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let pre = m * turn.inverse(); + let lit = light::Lighting::from_raw(raw); + extrude::draw(ctx, &outline, w, h, &cam, ex.depth, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + } + // A 3-D contour: a band around the outline, behind the face (and in front of any sides). + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(c) = extrude::contour_from_raw(raw) + { + let face = match &fill { + Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), + _ => None, + }; + let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); + extrude::draw_contour(ctx, &outline, m, c.width, col, &light::Lighting::from_raw(raw)); + } // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -1287,7 +1421,10 @@ impl Renderer { ctx.set_paint(color(c.with_alpha(255), 1.0)); ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); ctx.stroke_path(&silhouette); - ctx.fill_path(&silhouette); + // A shape without a fill (an outline, a frame) glows along its line only (#225). + if fill_alpha.is_some() || content.is_some() { + ctx.fill_path(&silhouette); + } ctx.pop_layer(); } if let Some(rf) = &e.reflection { @@ -1442,6 +1579,7 @@ pub(crate) fn stroke( if let Some(d) = &line.dash { let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); let pat = dash_with_caps(pat, cap, sw); + let pat = limit_dash_count(path, pat); if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { if cap == kurbo::Cap::Square { sk = sk.with_caps(kurbo::Cap::Butt); @@ -1582,6 +1720,30 @@ fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { pat } +/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a +/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would +/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the +/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then. +const MAX_DASHES: f64 = 20_000.0; + +fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { + use kurbo::ParamCurveArclen; + let period: f64 = pat.iter().sum(); + if !(period.is_finite() && period > 0.0) { + return pat; + } + let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); + let dashes = len / period; + if !len.is_finite() { + return vec![]; + } + if dashes <= MAX_DASHES { + return pat; + } + let k = dashes / MAX_DASHES; + pat.into_iter().map(|v| v * k).collect() +} + fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { let els: Vec = path.elements().to_vec(); let mut pts: Vec = vec![]; @@ -1850,7 +2012,7 @@ thread_local! { static GLYPH_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); } -fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { +pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { let key = (face.id(), gid); if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { return p; @@ -2107,6 +2269,49 @@ fn draw_glyph_with( } } +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/light.rs b/crates/render/src/light.rs new file mode 100644 index 00000000..2d475dfe --- /dev/null +++ b/crates/render/src/light.rs @@ -0,0 +1,184 @@ +//! Light rigs (`a:lightRig rig= dir=`) and materials (`a:sp3d prstMaterial=`) for 3-D shapes: +//! how bright a surface is for its normal. +//! +//! The three-point rig with the matte material is fitted to PowerPoint's exports (see +//! [`crate::bevel`]): `c · (0.453 + 0.731 · max(0, n · L)) + 14.7` with the key light from the upper +//! left. The other rigs and materials are modelled on it, not measured: each rig is a set of +//! directional lights with an ambient level and a colour tint, each material scales the diffuse +//! and ambient terms and adds a specular highlight. + +use deckcraft_color::Rgba; + +/// A light rig and material: what lights a 3-D surface. +#[derive(Clone, Debug, PartialEq)] +pub struct Lighting { + /// Unit directions from the surface towards each light (x right, y down, z to the viewer) + /// and their strengths. + pub lights: Vec<([f64; 3], f64)>, + pub ambient: f64, + pub diffuse: f64, + /// Added to every channel (the measured lift of the three-point rig). + pub lift: f64, + /// Colour of the light, per channel. + pub tint: [f64; 3], + pub specular: f64, + pub shininess: f64, + /// `flat`: no shading at all. + pub flat: bool, +} + +fn dir(az: f64, el: f64) -> [f64; 3] { + let (az, el) = (az.to_radians(), el.to_radians()); + [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] +} + +fn value_after<'a>(raw: &'a str, key: &str) -> Option<&'a str> { + let s = raw.find(key)? + key.len(); + let rest = raw.get(s..)?; + rest.get(..rest.find('"')?) +} + +/// The rig's lights as (azimuth, elevation, strength), its ambient level and tint. +fn rig(name: &str) -> (Vec<(f64, f64, f64)>, f64, [f64; 3]) { + const WHITE: [f64; 3] = [1.0, 1.0, 1.0]; + match name { + "balanced" => (vec![(240.0, 40.0, 0.75), (300.0, 40.0, 0.35), (90.0, 30.0, 0.25)], 0.47, WHITE), + "soft" => (vec![(270.0, 60.0, 0.6)], 0.58, WHITE), + "harsh" => (vec![(225.0, 30.0, 1.15)], 0.3, WHITE), + "flood" => (vec![(270.0, 70.0, 0.8), (240.0, 40.0, 0.35)], 0.5, WHITE), + "contrasting" => (vec![(210.0, 30.0, 1.05), (30.0, 20.0, 0.3)], 0.33, WHITE), + "morning" => (vec![(240.0, 35.0, 0.9)], 0.45, [1.0, 0.95, 0.85]), + "sunrise" => (vec![(200.0, 20.0, 0.95)], 0.42, [1.0, 0.88, 0.75]), + "sunset" => (vec![(340.0, 20.0, 0.95)], 0.42, [1.0, 0.82, 0.7]), + "chilly" => (vec![(240.0, 45.0, 0.85)], 0.45, [0.88, 0.94, 1.0]), + "freezing" => (vec![(240.0, 45.0, 0.85)], 0.45, [0.8, 0.9, 1.0]), + "twoPt" => (vec![(225.0, 45.0, 0.8), (315.0, 45.0, 0.45)], 0.42, WHITE), + "glow" => (vec![(270.0, 85.0, 0.55)], 0.68, WHITE), + "brightRoom" => (vec![(270.0, 60.0, 0.45)], 0.75, WHITE), + // threePt and the legacy rigs. + _ => (vec![(240.0, 45.0, 1.0)], 0.453, WHITE), + } +} + +/// The rig direction (`dir`) turns the lights about the view axis. +fn dir_turn(d: &str) -> f64 { + match d { + "tr" => 45.0, + "r" => 90.0, + "br" => 135.0, + "b" => 180.0, + "bl" => 225.0, + "l" => 270.0, + "tl" => 315.0, + _ => 0.0, + } +} + +impl Lighting { + /// The lighting of 3-D XML kept from the file; the three-point rig and warm matte material + /// when unset. + pub fn from_raw(raw: &str) -> Lighting { + let rig_name = value_after(raw, "rig=\"").unwrap_or("threePt"); + let turn = dir_turn(value_after(raw, " dir=\"").unwrap_or("t")); + let material = value_after(raw, "prstMaterial=\"").unwrap_or("warmMatte"); + let (lights, ambient, tint) = rig(rig_name); + let (dk, ak, specular, shininess) = match material { + "plastic" => (1.0, 1.0, 0.45, 24.0), + "metal" => (0.8, 0.9, 0.7, 40.0), + "softmetal" => (0.9, 0.95, 0.35, 12.0), + "powder" | "translucentPowder" => (0.85, 1.12, 0.0, 1.0), + "dkEdge" => (1.12, 0.8, 0.1, 8.0), + "softEdge" => (0.9, 1.08, 0.05, 6.0), + "clear" => (0.9, 1.0, 0.6, 50.0), + _ => (1.0, 1.0, 0.0, 1.0), + }; + Lighting { + lights: lights.into_iter().map(|(az, el, s)| (dir(az + turn, el), s)).collect(), + ambient: ambient * ak, + diffuse: 0.731 * dk, + lift: 14.7, + tint, + specular, + shininess, + flat: rig_name == "flat" || material == "flat", + } + } + + /// The diffuse brightness factor for unit normal `n` (the three-point matte rig: 0.453 + + /// 0.731 · max(0, n · L)). + pub fn factor(&self, n: [f64; 3]) -> f64 { + if self.flat { + return 1.0; + } + let dot = |l: &[f64; 3]| (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + self.ambient + self.diffuse * self.lights.iter().map(|(l, s)| s * dot(l)).sum::() + } + + /// The specular highlight (0–1) for unit normal `n`, seen from the front. + pub fn highlight(&self, n: [f64; 3]) -> f64 { + if self.flat || self.specular <= 0.0 { + return 0.0; + } + self.lights + .iter() + .map(|(l, s)| { + let h = [l[0], l[1], l[2] + 1.0]; + let len = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt().max(1e-9); + let d = ((n[0] * h[0] + n[1] * h[1] + n[2] * h[2]) / len).max(0.0); + s * d.powf(self.shininess) + }) + .sum::() + * self.specular + } + + /// Colour `c` lit on a surface with unit normal `n`. + pub fn shade(&self, c: Rgba, n: [f64; 3]) -> Rgba { + if self.flat { + return c; + } + let f = self.factor(n); + let hi = self.highlight(n) * 255.0; + let ch = |v: u8, t: f64| (f64::from(v) * f * t + self.lift + hi).round().clamp(0.0, 255.0) as u8; + Rgba::rgba(ch(c.r, self.tint[0]), ch(c.g, self.tint[1]), ch(c.b, self.tint[2]), c.a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn three_point_matte_is_the_measured_formula() { + let l = Lighting::from_raw(r#""#); + let n = [0.0, 0.0, 1.0]; + let expect = 0.453 + 0.731 * 45f64.to_radians().sin(); + assert!((l.factor(n) - expect).abs() < 1e-9); + assert_eq!(l.highlight(n), 0.0); + let c = l.shade(Rgba::rgb(100, 100, 100), n); + assert_eq!(c.r, (100.0 * expect + 14.7_f64).round() as u8); + } + + #[test] + fn rigs_and_materials_differ_as_named() { + let face = [0.0, 0.0, 1.0]; + let f = |raw: &str| Lighting::from_raw(raw).factor(face); + assert!(f(r#"rig="brightRoom""#) > f(r#"rig="threePt""#), "a bright room is brighter"); + assert!(f(r#"rig="harsh""#) < f(r#"rig="soft""#) + 0.2); + assert_eq!(Lighting::from_raw(r#"rig="flat""#).shade(Rgba::rgb(10, 20, 30), face), Rgba::rgb(10, 20, 30)); + // Metal shines where plastic shines less and matte not at all. + let up_left = { + let d = dir(240.0, 45.0); + let h = [d[0], d[1], d[2] + 1.0]; + let l = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt(); + [h[0] / l, h[1] / l, h[2] / l] + }; + let hi = |m: &str| Lighting::from_raw(&format!(r#"prstMaterial="{m}""#)).highlight(up_left); + assert!(hi("metal") > hi("plastic") && hi("plastic") > 0.0 && hi("matte") == 0.0); + // Turning the rig to the right moves the key light: a right-facing surface gets brighter. + let right = [0.7, 0.0, 0.7]; + assert!(Lighting::from_raw(r#"rig="threePt" dir="r""#).factor(right) > Lighting::from_raw(r#"rig="threePt" dir="t""#).factor(right)); + // Sunset is warm. + let warm = Lighting::from_raw(r#"rig="sunset""#).shade(Rgba::rgb(200, 200, 200), face); + assert!(warm.r > warm.b); + } +} diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs index 12350971..bb9da83f 100644 --- a/crates/render/src/metafile.rs +++ b/crates/render/src/metafile.rs @@ -51,7 +51,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { ctx.push_clip_layer(&r.to_path(0.1)); for it in pic.place(r, [0.0; 4]) { match it { - PlacedItem::Path { path, fill, stroke, even_odd } => { + PlacedItem::Path { path, fill, stroke, even_odd, region } => clipped(&mut ctx, region.as_ref(), |ctx| { if let Some(f) = fill { ctx.set_paint(rgba(f)); ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); @@ -63,9 +63,9 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&path); } - } - PlacedItem::Bitmap { index, rect } => { - let Some(Some(pm)) = pixmaps.get(index) else { continue }; + }), + PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_ref(), |ctx| { + let Some(Some(pm)) = pixmaps.get(index) else { return }; let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm.clone()), @@ -74,9 +74,9 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh)); ctx.fill_rect(&rect); ctx.reset_paint_transform(); - } - PlacedItem::Pattern { index, path, even_odd, tile } => { - let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue }; + }), + PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_ref(), |ctx| { + let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { return }; let key = Arc::as_ptr(t); let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone(); ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); @@ -101,17 +101,17 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { ctx.fill_path(&path); } } - } - PlacedItem::Text { index, transform, clip } => { - let Some(Item::Text(t)) = pic.items.get(index) else { continue }; + }), + PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_ref(), |ctx| { + let Some(Item::Text(t)) = pic.items.get(index) else { return }; if let Some(c) = clip { ctx.push_clip_layer(&c.to_path(0.1)); } - draw_text(&mut ctx, t, transform); + draw_text(ctx, t, transform); if clip.is_some() { ctx.pop_layer(); } - } + }), } } ctx.set_fill_rule(peniko::Fill::NonZero); @@ -133,6 +133,17 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { image::RgbaImage::from_raw(w as u32, h as u32, px) } +/// Draws `draw` inside the device-context clip. No region is the default clip. +fn clipped(ctx: &mut RenderContext, region: Option<&BezPath>, draw: impl FnOnce(&mut RenderContext)) { + if let Some(region) = region { + ctx.push_clip_layer(region); + draw(ctx); + ctx.pop_layer(); + } else { + draw(ctx); + } +} + /// One glyph placed in text space (x along the baseline from the string's start, font units at /// `scale` text units each). struct Glyph { diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs index 6579eb18..6947053f 100644 --- a/crates/render/src/morph_path.rs +++ b/crates/render/src/morph_path.rs @@ -29,12 +29,72 @@ fn ratio(now: f64, own: f64) -> f64 { if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 } } -/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their -/// own sizes (so the alignment does not change while the box grows), then scaled into the box. -/// With a [`Fade`] per sub-path. -pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { - let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h); - let b = shape_geometry(s, pm.to_box.w, pm.to_box.h); - let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); - GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box)) +/// What a [`GeometryMorph`] is built from: both outlines at their own sizes. None of it changes +/// while a transition plays, only `t` and the current box do. +#[derive(Debug)] +struct Source { + from: Geom, + from_wh: (f64, f64), + to: Geom, + to_wh: (f64, f64), +} + +impl Source { + fn of(pm: &PathMorph, s: &Shape) -> Self { + Source { from: pm.from.clone(), from_wh: (pm.from_box.w, pm.from_box.h), to: s.geom.clone(), to_wh: (pm.to_box.w, pm.to_box.h) } + } + + fn is(&self, pm: &PathMorph, s: &Shape) -> bool { + self.from == pm.from && self.from_wh == (pm.from_box.w, pm.from_box.h) && self.to == s.geom && self.to_wh == (pm.to_box.w, pm.to_box.h) + } + + fn build(&self) -> GeometryMorph { + let geo = |geom: &Geom, (w, h): (f64, f64)| shape_geometry(&Shape { geom: geom.clone(), ..Shape::default() }, w, h); + GeometryMorph::new(&geo(&self.from, self.from_wh), &geo(&self.to, self.to_wh)) + } +} + +/// The aligned outlines of the morphs being played, kept across frames: aligning is the expensive +/// part, blending them at `t` is cheap. +#[derive(Debug, Default)] +pub(crate) struct Cache { + /// With whether the current frame used it. + morphs: Vec<(Source, GeometryMorph, bool)>, +} + +impl Cache { + /// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at + /// their own sizes (so the alignment does not change while the box grows), then scaled into the + /// box. With a [`Fade`] per sub-path. + pub(crate) fn geometry(&mut self, pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { + let k = match self.morphs.iter().position(|(src, ..)| src.is(pm, s)) { + Some(k) => k, + None => { + let src = Source::of(pm, s); + let m = src.build(); + self.morphs.push((src, m, false)); + self.morphs.len() - 1 + } + }; + let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); + let Some((_, m, used)) = self.morphs.get_mut(k) else { + // Not reachable (k was just found or pushed); align afresh rather than panic. + return Source::of(pm, s).build().at(pm.t, scale(pm.from_box), scale(pm.to_box)); + }; + *used = true; + m.at(pm.t, scale(pm.from_box), scale(pm.to_box)) + } + + /// Ends a frame: forgets the morphs it did not use (their transition is over). + pub(crate) fn sweep(&mut self) { + self.morphs.retain(|(.., used)| *used); + for (.., used) in &mut self.morphs { + *used = false; + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.morphs.len() + } } diff --git a/crates/render/src/pathgrad.rs b/crates/render/src/pathgrad.rs index 7402ab2c..168d9e0a 100644 --- a/crates/render/src/pathgrad.rs +++ b/crates/render/src/pathgrad.rs @@ -18,6 +18,13 @@ use vello_cpu::{Pixmap, RenderContext, peniko}; /// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest). const MAX_SIDE: f64 = 384.0; +/// Most pixel × outline-edge tests one `shape` gradient may cost; a denser outline is simplified +/// to fit (each pixel tests every edge). +const MAX_EDGE_WORK: u64 = 64_000_000; + +/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape. +const MIN_POLY_EDGES: usize = 8; + /// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the /// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the /// shape's flattened polygon(s) for `shape`. @@ -136,6 +143,65 @@ pub fn polygons(path: &BezPath) -> Vec> { out } +/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points +/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too +/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits. +fn simplify(outline: Vec>, tol: f64, max_edges: usize) -> Vec> { + let edges = |o: &[Vec]| o.iter().map(|p| p.len().saturating_sub(1)).sum::(); + if edges(&outline) <= max_edges { + return outline; + } + let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.0 }; + let mut out: Vec> = outline + .into_iter() + .map(|poly| { + let mut kept: Vec = Vec::with_capacity(poly.len()); + let last = poly.last().copied(); + for p in poly { + if kept.last().is_none_or(|q| (p - *q).hypot() >= tol) { + kept.push(p); + } + } + if let (Some(l), Some(k)) = (last, kept.last().copied()) + && k != l + { + kept.push(l); + } + kept + }) + .filter(|p| p.len() > 2) + .collect(); + let total = edges(&out); + if total > max_edges { + let k = total.div_ceil(max_edges.max(1)).max(2); + for poly in &mut out { + let n = poly.len(); + if n <= MIN_POLY_EDGES + 1 { + continue; + } + let k = k.min((n - 1) / MIN_POLY_EDGES).max(1); + let last = poly.last().copied(); + let mut kept: Vec = poly.iter().step_by(k).copied().collect(); + if let (Some(l), Some(q)) = (last, kept.last().copied()) + && q != l + { + kept.push(l); + } + *poly = kept; + } + // Polygons too small to thin stay whole; drop the smallest ones if they alone overflow. + if edges(&out) > max_edges { + out.sort_by_key(|p| std::cmp::Reverse(p.len())); + let mut sum = 0usize; + out.retain(|p| { + sum = sum.saturating_add(p.len().saturating_sub(1)); + sum <= max_edges + }); + } + } + out +} + /// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them). pub fn ramp(stops: &[(f64, Rgba)]) -> Vec { (0..256) @@ -166,7 +232,14 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, let longest = bounds.width().max(bounds.height()) * k; let s = (MAX_SIDE / longest).min(1.0) * k; let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096)); - let outline = if kind == "shape" { polygons(path) } else { Vec::new() }; + let outline = if kind == "shape" { + let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1); + let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES); + let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph)); + simplify(polygons(path), pixel, max_edges) + } else { + Vec::new() + }; let lut = ramp(stops); let a = alpha.clamp(0.0, 1.0); let mut data = Vec::with_capacity((pw * ph) as usize); @@ -252,6 +325,53 @@ mod tests { assert!((half - 0.5).abs() < 0.02, "{half}"); } + fn many_segment_square(n: usize) -> BezPath { + // A 200x200 square whose sides are split into `n` segments each. + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + let corners = [(200.0, 0.0), (200.0, 200.0), (0.0, 200.0), (0.0, 0.0)]; + let mut from = (0.0, 0.0); + for to in corners { + for i in 1..=n { + let t = i as f64 / n as f64; + p.line_to((from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)); + } + from = to; + } + p.close_path(); + p + } + + #[test] + fn dense_outlines_are_simplified_to_the_work_budget() { + // Below the per-polygon flatten cap, so the simplifier has to do the work. + let polys = polygons(&many_segment_square(20_000)); + assert!(polys.iter().map(|p| p.len() - 1).sum::() >= 80_000); + let b = Rect::new(0.0, 0.0, 200.0, 200.0); + let simple = simplify(polys, 200.0 / 384.0, 400); + let edges: usize = simple.iter().map(|p| p.len() - 1).sum(); + assert!((MIN_POLY_EDGES..=400).contains(&edges), "{edges}"); + // Still the square: half way to a side or a corner is 0.5. + for q in [Point::new(150.0, 100.0), Point::new(150.0, 150.0), Point::new(100.0, 50.0)] { + let t = position("shape", b, CENTRE, &simple, q); + assert!((t - 0.5).abs() < 0.01, "{q:?} {t}"); + } + // Small outlines pass through untouched. + let tri = vec![vec![Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]]; + assert_eq!(simplify(tri.clone(), 10.0, 400), tri); + } + + #[test] + fn a_dense_shape_gradient_renders_promptly() { + let path = many_segment_square(20_000); + let mut ctx = RenderContext::new(400, 400); + let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))]; + let t = std::time::Instant::now(); + fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 200.0, 200.0), &path, Affine::scale(2.0), 1.0); + // Unbudgeted this is 147k pixels × 80k edges ≈ 1.2e10 tests (minutes); budgeted, well under a second in release. + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } + #[test] fn the_ramp_runs_through_the_stops() { let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))]; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0a0d4737..0164f3f0 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -3,7 +3,7 @@ use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Hyperlink, TextBody}; -use deckcraft_model::{Presentation, Shape, ShapeKind}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use deckcraft_text::{Opts, TextLayout}; use kurbo::{Affine, Rect}; @@ -22,12 +22,32 @@ pub struct PlacedLink { /// Slide-space bounding box. pub rect: Rect, pub link: Hyperlink, + /// The shape that owns the link (for a text hyperlink, the shape holding the text). + pub shape: ShapeId, + /// A hyperlink on text, not the shape's own action. + pub text: bool, +} + +/// A shape of the slide itself (not of its layout or master) with its slide-space bounding box: +/// what a click in a slide show lands on. +pub struct PlacedShape { + pub id: ShapeId, + /// Index into [`Placed::shapes`] of the group this shape is in. + pub parent: Option, + pub is_group: bool, + /// Slide-space bounding box. + pub rect: Rect, + /// The shape's own action (Insert ▸ Action), not its text hyperlinks. + pub link: Option, } #[derive(Default)] pub struct Placed { pub texts: Vec, pub links: Vec, + /// The slide's own shapes in drawing order (back to front; a group before its children). Hidden + /// shapes are left out. + pub shapes: Vec, } /// Text and links of slide `index` (master and layout graphics first, as rendered). @@ -40,22 +60,49 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed { if show_master { let mctx = Ctx::for_master(pres, rctx.master); for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) { - place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0, None, false); } } if show_layout && let Some(l) = rctx.layout { let lctx = Ctx::for_layout(pres, rctx.master, l); for s in l.shapes.iter().filter(|s| s.ph.is_none()) { - place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0, None, false); } } for s in &slide.shapes { - place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0, None, true); } out } -fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize) { +/// The header, date, footer and page-number text of a notes or handout `master`, laid out in its +/// placeholders (master points) with their own formatting and wrapping, for page `n`. The page +/// number placeholder shows `n` in its first run's formatting. +pub fn place_master_texts(pres: &Presentation, master: &deckcraft_model::Master, n: usize) -> Vec { + use deckcraft_model::PhType; + let mut out = Placed::default(); + let ctx = Ctx::for_master(pres, master); + let fields = SlideFields::new(u32::try_from(n).unwrap_or(u32::MAX), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default()); + for s in master.shapes.iter().filter(|s| matches!(s.ph_type(), Some(PhType::Header | PhType::Date | PhType::Footer | PhType::SlideNum))) { + let mut s = s.clone(); + if s.ph_type() == Some(PhType::SlideNum) { + let mut body = s.text.clone().unwrap_or_default(); + let props = + body.paragraphs.first().and_then(|p| p.runs.first().map(|r| r.props.clone()).or(Some(p.end_props.clone()))).unwrap_or_default(); + let mut para = body.paragraphs.first().cloned().unwrap_or_default(); + para.runs = vec![deckcraft_model::text::Run::with(n.to_string(), props)]; + body.paragraphs = vec![para]; + s.text = Some(body); + } + place(&mut out, &ctx, &fields, &s, Affine::IDENTITY, 0, None, false); + } + out.texts +} + +/// `group` is the index in `out.shapes` of the enclosing group; `own` is true for the slide's own +/// shapes (recorded in `out.shapes`), false for the layout's and master's. +#[allow(clippy::too_many_arguments)] +fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize, group: Option, own: bool) { if depth > 32 || s.hidden { return; } @@ -64,15 +111,22 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: return; } let m = parent * x.affine(); + let rect = m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)); if let Some(link) = &s.click { - out.links.push(PlacedLink { rect: m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)), link: link.clone() }); + out.links.push(PlacedLink { rect, link: link.clone(), shape: s.id, text: false }); } + let me = if own { + out.shapes.push(PlacedShape { id: s.id, parent: group, is_group: matches!(s.kind, ShapeKind::Group { .. }), rect, link: s.click.clone() }); + Some(out.shapes.len() - 1) + } else { + None + }; match &s.kind { ShapeKind::Group { children, child } => { let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); let gm = parent * deckcraft_geom::group_child_affine(&x, ch); for c in children { - place(out, rctx, fields, c, gm, depth + 1); + place(out, rctx, fields, c, gm, depth + 1, me, own); } } ShapeKind::Table(t) => { @@ -81,20 +135,21 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let body = table::cell_body(rctx, t, r, c, cell); let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - place_text(out, rctx, fields, &tmp, &body, rect, m); + place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); } } ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {} _ => { let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return }; let tr = shape_geometry(s, x.w, x.h).text_rect; - place_text(out, rctx, fields, s, body, tr, m); + place_text(out, rctx, fields, s.id, s, body, tr, m); } } } /// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks. -fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { +#[allow(clippy::too_many_arguments)] +fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let m = crate::text_transform(m, tr, layout.rotation); // Text hyperlinks: one box per line segment of a linked run. @@ -105,7 +160,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, bod let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); let r = Rect::new(x0, line.top, x1, line.bottom); - out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link }); + out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); } out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); } @@ -123,3 +178,32 @@ fn run_link(body: &TextBody, para: usize, at: usize) -> Option { } None } + +/// The outlines of shape `s`'s text as drawn, in the space `m` maps shape-local points to, and +/// the colour of its first visible run: what Merge Shapes turns letters into. Empty when the +/// shape has no text. +pub fn text_outline(rctx: &Ctx, s: &Shape, m: Affine) -> (kurbo::BezPath, Option) { + let mut out = kurbo::BezPath::new(); + let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return (out, None) }; + let x = resolve::xfrm(rctx, s); + let tr = shape_geometry(s, x.w, x.h).text_rect; + let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }); + let m = crate::text_transform(m, tr, layout.rotation); + let mut first = None; + for run in &layout.runs { + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + for (gid, gx, gy) in &run.glyphs { + let path = crate::glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + if first.is_none() && run.color.a > 0 { + first = Some(run.color); + } + let at = m * crate::glyph_place(run, *gid, *gx, *gy) * skew * Affine::scale(k); + out.extend(path.elements().iter().map(|e| at * *e)); + } + } + (out, first) +} diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs new file mode 100644 index 00000000..3416c7ce --- /dev/null +++ b/crates/render/src/radar.rs @@ -0,0 +1,187 @@ +//! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon +//! of gridlines per major unit and each series a closed polygon (filled for a filled radar). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_fill, series_line}; +use crate::color; + +/// The point at `frac` of the radius along spoke `i` of `n` (the first spoke points up). +fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n.max(1) as f64; + Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin()) +} + +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if n < 3 { + // Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing. + return; + } + let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); + // Room for the category labels around the outside. + let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base); + let cen = area.center(); + let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2)); + let span = (max - min).max(1e-9); + let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20); + ctx.set_transform(m); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.set_paint(color(grid_col, 1.0)); + if c.gridlines { + for k in 1..=ticks { + let frac = k as f64 / ticks as f64; + let mut p = BezPath::new(); + for i in 0..n { + let q = spoke(cen, r, i, n, frac); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + ctx.stroke_path(&p); + } + } + for i in 0..n { + let mut p = BezPath::new(); + p.move_to(cen); + p.line_to(spoke(cen, r, i, n, 1.0)); + ctx.set_transform(m); + ctx.set_paint(color(grid_col, 1.0)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i) { + let q = spoke(cen, r + base * 1.3, i, n, 1.0); + let w = name.chars().count() as f64 * base * 0.55 + base; + let align = if (q.x - cen.x).abs() < base { + Align::Center + } else if q.x > cen.x { + Align::Left + } else { + Align::Right + }; + let x0 = match align { + Align::Left => q.x - base * 0.3, + Align::Right => q.x - w + base * 0.3, + _ => q.x - w / 2.0, + }; + label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + } + } + // Value labels up the first spoke. + if !c.y_hidden { + for k in 0..=ticks { + let v = min + step * k as f64; + let q = spoke(cen, r, 0, n, k as f64 / ticks as f64); + let t = c.axis_label(false, v); + let w = t.chars().count() as f64 * base * 0.55 + base * 0.4; + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), + base * 0.9, + Align::Right, + Anchor::Middle, + text_col, + false, + ); + } + } + let filled = c.kind == ChartType::FilledRadar; + for (si, s) in c.series.iter().enumerate() { + let pts: Vec = (0..n) + .map(|i| { + let v = s.values.get(i).copied().flatten().unwrap_or(min); + spoke(cen, r, i, n, ((v - min) / span).clamp(0.0, 1.2)) + }) + .collect(); + let mut p = BezPath::new(); + for (i, q) in pts.iter().enumerate() { + if i == 0 { p.move_to(*q) } else { p.line_to(*q) } + } + p.close_path(); + ctx.set_transform(m); + if filled { + ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 0.75)); + ctx.fill_path(&p); + } else { + let col = series_line(rctx, c, si, s); + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round)); + ctx.stroke_path(&p); + if s.marker.is_some() { + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } + if c.data_labels || s.data_labels == Some(true) { + for (i, q) in pts.iter().enumerate() { + if let Some(v) = s.values.get(i).copied().flatten() { + let t = c.data_label(s, v); + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), + base * 0.9, + Align::Center, + Anchor::Bottom, + text_col, + false, + ); + } + } + } + } +} + +/// A stock chart's marks at one category: the high-low line, then the up/down bar between the +/// first (open) and last (close) series, else a dash at the close. +#[allow(clippy::too_many_arguments)] +pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) { + let vals: Vec = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect(); + if vals.len() < 2 { + return; + } + let hi = vals.iter().copied().fold(f64::MIN, f64::max); + let lo = vals.iter().copied().fold(f64::MAX, f64::min); + let ink = Rgba::rgb(0x40, 0x40, 0x40); + ctx.set_transform(m); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + let mut p = BezPath::new(); + p.move_to((x, to_y(hi))); + p.line_to((x, to_y(lo))); + ctx.stroke_path(&p); + let open = c.series.first().and_then(|s| s.values.get(i).copied().flatten()); + let close = c.series.last().and_then(|s| s.values.get(i).copied().flatten()); + if c.up_down_bars && c.series.len() >= 4 { + let (Some(o), Some(cl)) = (open, close) else { return }; + let w = band * 0.4; + let (y0, y1) = (to_y(o.max(cl)), to_y(o.min(cl))); + let r = Rect::new(x - w / 2.0, y0, x + w / 2.0, y1.max(y0 + 1.0)); + // PowerPoint's default: white when the price rose, dark when it fell. + ctx.set_paint(color(if cl >= o { Rgba::WHITE } else { ink }, 1.0)); + ctx.fill_rect(&r); + ctx.set_paint(color(ink, 1.0)); + ctx.stroke_path(&r.to_path(0.1)); + return; + } + if let Some(cl) = close { + let col = c.series.last().map(|s| series_line(rctx, c, c.series.len() - 1, s)).unwrap_or(ink); + let y = to_y(cl); + let mut d = BezPath::new(); + d.move_to((x - band * 0.12, y)); + d.line_to((x + band * 0.12, y)); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.5)); + ctx.stroke_path(&d); + } +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index c84e1172..545d060e 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -224,7 +224,7 @@ fn edges(sizes: &[f64]) -> Vec { } /// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold. -pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { +pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { let lk = look(rctx, t, r, c); let mut body = cell.text.clone(); let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]); @@ -297,6 +297,25 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { out } +/// Can a cell's text be left undrawn because it lands wholly outside the `target` (pixels)? Only +/// for cells whose text stays inside the cell: its row grew to fit it (one row, horizontal text, +/// not at the height cap), so nothing overflows. A margin covers italic overhang and the like. +/// A table of thousands of rows otherwise lays out every cell's text off the slide's bottom. +fn text_off_target(cell: &Cell, rect: Rect, m: Affine, target: (u16, u16)) -> bool { + if cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) || rect.height() >= 20_000.0 { + return false; + } + let b = m.transform_rect_bbox(rect).inflate(64.0, 64.0); + let view = Rect::new(0.0, 0.0, f64::from(target.0), f64::from(target.1)); + b.is_finite() && (b.x1 < view.x0 || b.y1 < view.y0 || b.x0 > view.x1 || b.y0 > view.y1) +} + +#[cfg(test)] +thread_local! { + /// Cells whose text `draw` laid out on this thread (tests check off-slide cells are skipped). + pub(crate) static TEXT_CELLS_DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Draws the table; returns the box its cells cover (table space), which grows with their text. #[allow(clippy::too_many_arguments)] pub fn draw( @@ -399,11 +418,13 @@ pub fn draw( // Text. for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; - if cell.text.is_empty() { + if cell.text.is_empty() || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) { continue; } let body = cell_body(rctx, t, *r, *c, cell); let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + #[cfg(test)] + TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1)); crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None); let _ = peniko::Fill::NonZero; } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..c85a1e29 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use deckcraft_model::chart::ChartType; +use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; use deckcraft_model::text::TextBody; use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; @@ -35,6 +35,65 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn render_shapes_draws_the_selection_on_a_transparent_canvas() { + let sh = Shape { + id: ShapeId(880), + xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)), + fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))), + line: Some(deckcraft_model::Line::none()), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture"); + let mid = img.pixel(img.width / 2, img.height / 2); + assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}"); + assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0)); +} + +#[test] +fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() { + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts); + let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts); + let full = render_slide(&ink_line(1.0, 1.0), 0, &opts); + let heavy = dark_in(&tapered, 50, 140, 70, 130); + let light = dark_in(&tapered, 280, 360, 70, 130); + assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})"); + let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130)); + assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})"); +} + +fn ink_line(left: f32, right: f32) -> Presentation { + let points = (0..=40) + .map(|i| { + let pressure = if i < 20 { left } else { right }; + (40.0 + f64::from(i) * 8.0, 100.0, pressure) + }) + .collect(); + let sh = Shape { + id: ShapeId(7), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }], + }, + ..Default::default() + }; + deck_with(vec![sh]) +} + +fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize { + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + if img.pixel(x, y)[0] < 200 { + n += 1; + } + } + } + n +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; @@ -155,6 +214,223 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart { + let mut c = Chart::sample(kind); + c.title = Some(title.into()); + c.legend = None; + c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; + c.series.truncate(1); + c.series[0].name = "Data".into(); + c.series[0].values = ys.iter().copied().map(Some).collect(); + c.y_min = Some(0.0); + c.y_max = Some(y_max); + c.series[0].trendlines = vec![trend]; + c +} + +fn trend_line(kind: TrendlineType) -> Trendline { + Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() } +} + +/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit. +fn trend_gallery(draw: bool, forecast: bool) -> Image { + let mut linear = trend_line(TrendlineType::Linear); + if forecast { + linear.forward = Some(1.0); + linear.backward = Some(0.5); + } + let mut poly = trend_line(TrendlineType::Poly); + poly.order = Some(2); + let mut ma = trend_line(TrendlineType::MovingAvg); + ma.period = Some(2); + let mut charts = vec![ + trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0), + trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0), + trend_chart( + "Logarithmic", + ChartType::LineMarkers, + &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()], + trend_line(TrendlineType::Log), + 2.0, + ), + trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0), + trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0), + trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0), + ]; + if !draw { + for c in &mut charts { + c.series[0].trendlines.clear(); + } + } + let shapes = charts + .into_iter() + .enumerate() + .map(|(i, c)| Shape { + id: ShapeId(800 + i as u32), + xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }) + .collect(); + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn trendlines_draw_through_the_points_with_a_label() { + let on = trend_gallery(true, false); + let off = trend_gallery(false, false); + assert_eq!((on.width, on.height), (960, 540)); + let dark = on + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(off.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(dark > 40, "dashed trendline ink {dark}"); + // A scatter fit uses the real x values and still paints the dash. + let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0); + scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect(); + let sh = Shape { + id: ShapeId(810), + xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), + kind: ShapeKind::Chart(Box::new(scatter.clone())), + ..Default::default() + }; + let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + scatter.series[0].trendlines.clear(); + let sh = + Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() }; + let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let scatter_dark = with + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(without.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}"); + if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() { + let png = trend_gallery(true, true).to_png(); + std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot"); + } +} + +#[test] +fn chart_axis_titles_are_drawn() { + let draw = |titles: (Option, Option)| { + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.axis_titles = titles; + let sh = Shape { id: ShapeId(624), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let ink = |img: &Image, r: (u32, u32, u32, u32)| { + (r.0..r.2).flat_map(|x| (r.1..r.3).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 160).count() + }; + let bare = draw((None, None)); + let titled = draw((Some("Month".into()), Some("Revenue".into()))); + // A turned "Revenue" down the left edge, "Month" along the bottom. + let left = (38, 150, 62, 390); + let bottom = (380, 490, 580, 520); + assert!(ink(&titled, left) > ink(&bare, left) + 30, "value axis title"); + assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title"); +} + +#[test] +fn column_points_take_their_own_fill_and_smooth_lines_curve() { + // One column series where only the middle point is given a red c:dPt fill. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]); + assert!(red(480), "the middle column takes its point's fill"); + assert!(!red(200) && !red(760), "the others keep the series colour"); + + // Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and + // LibreOffice's geometry), not band / 2.5 split in two. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(2); + c.categories = vec!["A".into()]; + c.series[0].values = vec![Some(10.0)]; + c.series[1].values = vec![Some(10.0)]; + c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + (c.gap_width, c.overlap) = (1.5, 0.0); + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5; + let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64; + // The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band + // was split as 1 + gap and then shared between the series). + assert!((200.0..280.0).contains(&reds), "bar {reds} px"); + + // A smoothed line through a peak overshoots no point but leaves the straight chord. + let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; + let p = crate::chart::smooth_path(&pts); + assert_eq!(p.elements().len(), 3, "a cubic per segment"); + let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5); + assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); +} + +#[test] +fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() { + let draw = |reversed: bool| { + let mut c = Chart::sample(ChartType::Bar); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + c.cat_reversed = reversed; + let sh = Shape { id: ShapeId(623), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let rows: Vec = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect(); + (rows[0] + rows[rows.len() - 1]) / 2 + }; + assert!(draw(false) > 360, "first category at the bottom: {}", draw(false)); + assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true)); +} + +#[test] +fn glow_of_an_unfilled_shape_follows_its_line() { + // A frame (no fill, thin line) with a strong glow: the glow lines its edge, the inside stays + // the slide's white, as PowerPoint draws it. + let glow = |fill: Option| { + let mut sh = Shape { + id: ShapeId(610), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)), + fill, + line: Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 240)), 0.25)), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 8.0 }), + ..Default::default() + }); + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let img = glow(Some(Fill::None)); + let inside = img.pixel(300, 250); + assert!(inside[0] > 245 && inside[1] > 245, "{inside:?}"); + let edge = img.pixel(300, 97); + assert!(edge[0] < 230, "{edge:?}"); + // A filled shape's glow is still the whole silhouette's (the fill covers its inside). + let img = glow(Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255))))); + assert!(img.pixel(300, 94)[0] < 240); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); @@ -171,9 +447,28 @@ fn png_encodes() { let img = render_slide(&p, 0, &RenderOpts { scale: 0.1, ..Default::default() }); let png = img.to_png(); assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); + assert_eq!(image_dpi(&png), None, "an untagged screenshot stays without a resolution"); assert!(!img.to_jpeg(80).is_empty()); } +#[test] +fn a_retina_export_records_144_dpi_and_still_decodes() { + assert_eq!(dpi_for_scale(2.0), 144.0); + assert_eq!(dpi_for_scale(1.0), 72.0); + assert_eq!(dpi_for_scale(f64::NAN), 72.0); + let img = Image { width: 4, height: 2, pixels: [255, 0, 0, 255].repeat(8) }; + let png = img.to_png_at(144.0); + assert_eq!(image_dpi(&png), Some((144.0, 144.0))); + let decoded = image::load_from_memory(&png).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (4, 2)); + let again = images::stamp_png_dpi(&png, 72.0); + assert_eq!(again.len(), png.len(), "a second pHYs chunk is not written"); + assert_eq!(image_dpi(&again), Some((144.0, 144.0))); + let jpeg = img.to_jpeg_at(90, Some(144.0)); + assert_eq!(image_dpi(&jpeg), Some((144.0, 144.0))); + assert_eq!(image_dpi(&img.to_jpeg(90)), None); +} + #[test] fn crop_dest_math() { let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]); @@ -361,7 +656,7 @@ fn two_frame_gif() -> Vec { #[test] fn animated_gif_shows_the_frame_for_its_time() { let mut p = Presentation::default(); - let media = p.add_media("a.gif", "image/gif", two_frame_gif()); + let media = p.add_media("a.gif", "image/gif", two_frame_gif()).expect("media"); let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; @@ -448,6 +743,29 @@ fn morph_path_fades_a_fill_only_one_side_has() { assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}"); } +#[test] +fn morph_path_aligns_once_per_transition() { + let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0)); + let star = Shape { geom: Geom::preset("star5"), ..Default::default() }; + let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; + let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::>(), f); + let mut cache = morph_path::Cache::default(); + for t in [0.0, 0.3, 0.7, 1.0] { + let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0)); + // The same as aligning afresh. + assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0))); + cache.sweep(); + } + assert_eq!(cache.len(), 1); + // Another target outline is another morph. + let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0); + assert_eq!(cache.len(), 2); + // A frame without morphs ends the transition: the cache empties. + cache.sweep(); + cache.sweep(); + assert_eq!(cache.len(), 0); +} + #[test] fn text_transform_unmirrors_flips() { // Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text. @@ -539,7 +857,14 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let px = |p: &Presentation| render_slide(p, 0, &o); let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count(); - let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let link = Some(Hyperlink { + action: Action::Url { url: "https://example.org".into() }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink))); let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))); let _ = px(&run_deck(RunProps::default())); // faces load lazily @@ -653,7 +978,7 @@ fn a_background_is_laid_over_white() { let mut png = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap(); let mut p = deck_with(vec![]); - let media = p.add_media("bg.png", "image/png", png.into_inner()); + let media = p.add_media("bg.png", "image/png", png.into_inner()).expect("media"); let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) }); let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); @@ -1149,7 +1474,7 @@ fn half_red_wmf() -> Vec { #[test] fn metafile_pictures_draw_their_vectors() { let mut p = Presentation::default(); - let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()); + let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()).expect("media"); let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; @@ -1174,7 +1499,7 @@ fn broken_metafiles_draw_nothing_and_never_panic() { cases.push(huge); for bytes in cases { let mut p = Presentation::default(); - let media = p.add_media("image1.emf", "image/x-emf", bytes); + let media = p.add_media("image1.emf", "image/x-emf", bytes).expect("media"); let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; @@ -1195,11 +1520,11 @@ fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() { }; for with_preview in [true, false] { let mut p = Presentation::default(); - let media = p.add_media("image1.png", "image/png", png.clone()); + let media = p.add_media("image1.png", "image/png", png.clone()).expect("media"); let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), - kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() }, + kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into(), parts: vec![] }, ..Default::default() }; std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; @@ -1567,7 +1892,7 @@ fn a_picture_casts_its_shadow_from_its_pixels() { }; for as_fill in [false, true] { let mut p = Presentation::default(); - let media = p.add_media("logo.png", "image/png", png.clone()); + let media = p.add_media("logo.png", "image/png", png.clone()).expect("media"); let pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() }; if as_fill { @@ -1711,3 +2036,476 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() { + use deckcraft_model::text::{Action, Hyperlink}; + let link = + Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }; + let rect = |id: u32, x: f64, click: Option| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)), + click, + ..Default::default() + }; + let group = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(300.0, 50.0, 200.0, 40.0)), + click: Some(link.clone()), + kind: ShapeKind::Group { children: vec![rect(11, 0.0, None), rect(12, 100.0, None)], child: Xfrm::new(0.0, 0.0, 200.0, 40.0) }, + ..Default::default() + }; + let hidden = Shape { hidden: true, ..rect(13, 0.0, Some(link.clone())) }; + let p = deck_with(vec![rect(1, 0.0, None), rect(2, 150.0, Some(link.clone())), hidden, group]); + let placed = place_slide(&p, 0); + // Back to front, a group before its children; the hidden shape is not there. + let ids: Vec = placed.shapes.iter().map(|s| s.id.0).collect(); + assert_eq!(ids, vec![1, 2, 10, 11, 12]); + // Only the shapes with an action carry a link. + let linked: Vec = placed.shapes.iter().filter(|s| s.link.is_some()).map(|s| s.id.0).collect(); + assert_eq!(linked, vec![2, 10]); + // The children know their group, whose box covers them. + let group = &placed.shapes[2]; + assert!(group.is_group && group.parent.is_none()); + for child in &placed.shapes[3..] { + assert_eq!(child.parent, Some(2)); + assert!(child.rect.x0 >= group.rect.x0 - 0.01 && child.rect.x1 <= group.rect.x1 + 0.01); + } + // Shape actions come out as links owned by their shape, not as text links. + assert!(placed.links.iter().all(|l| !l.text)); + assert_eq!(placed.links.iter().map(|l| l.shape.0).collect::>(), vec![2, 10]); +} + +#[test] +fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() { + use deckcraft_model::text::{Action, Hyperlink}; + let mut body = TextBody::from_text("click here"); + for r in &mut body.paragraphs[0].runs { + r.props.link = Some(Hyperlink { + action: Action::FirstSlide, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); + } + let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() }; + let placed = place_slide(&deck_with(vec![sh]), 0); + assert!(placed.links.iter().any(|l| l.text && l.shape == ShapeId(7))); + assert!(placed.shapes.iter().all(|s| s.link.is_none()), "a text link is not the shape's own action"); +} + +#[test] +fn metafile_intersect_clip_rect_hides_the_rest_of_the_picture() { + let rec = |typ: u32, payload: &[u8]| { + let mut v = typ.to_le_bytes().to_vec(); + v.extend(((8 + payload.len()) as u32).to_le_bytes()); + v.extend(payload); + v + }; + let rect = |l: i32, t: i32, r: i32, b: i32| [l, t, r, b].into_iter().flat_map(|v| v.to_le_bytes()).collect::>(); + let mut body = rec(30, &rect(0, 0, 50, 50)); + body.extend(rec(43, &rect(0, 0, 100, 50))); + let eof = rec(14, &[0; 12]); + let total = 88 + body.len() + eof.len(); + let mut file = vec![0u8; 88]; + file[0..4].copy_from_slice(&1u32.to_le_bytes()); + file[4..8].copy_from_slice(&88u32.to_le_bytes()); + file[8..24].copy_from_slice(&rect(0, 0, 100, 50)); + file[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); + file[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + file[48..52].copy_from_slice(&(total as u32).to_le_bytes()); + file[52..56].copy_from_slice(&2u32.to_le_bytes()); + file[56..58].copy_from_slice(&1u16.to_le_bytes()); + file.extend(body); + file.extend(eof); + let img = crate::metafile::rasterize(&file, 100).expect("raster"); + let opaque = |x: u32, y: u32| img.get_pixel(x, y)[3] > 200; + assert!(opaque(20, 25), "the clipped half is drawn"); + assert!(!opaque(80, 25), "past the clip the picture stays clear, pixel {:?}", img.get_pixel(80, 25)); +} + +#[test] +fn tiny_chart_does_not_blank_the_slide() { + // A chart a few points wide used to panic in `f64::clamp` (gutter min above max), and the + // panic guard then blanked the whole slide. Render directly, without the guard. + let red = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let mut shapes = vec![red]; + for (i, k) in [ChartType::Column, ChartType::Bar, ChartType::Line, ChartType::Combo, ChartType::Scatter].iter().enumerate() { + shapes.push(Shape { + id: ShapeId(801 + i as u32), + xfrm: Some(Xfrm::new(500.0 + i as f64 * 5.0, 300.0, 0.9, 0.9)), + kind: ShapeKind::Chart(Box::new(Chart::sample(*k))), + ..Default::default() + }); + } + let p = deck_with(shapes); + let img = RENDERER.with(|r| r.borrow_mut().slide(&p, &p.slides[0], 0, &RenderOpts { scale: 1.0, ..Default::default() })); + let px = img.pixel(200, 150); + assert_ne!(px, [255, 255, 255, 255], "the other shape must still draw"); + assert_eq!(crate::chart::clamp_soft(5.0, 10.5, 0.276), 0.276); +} + +/// Shapes with depth under turned cameras (an `extrusionH` and, on two, an `extrusionClr`). +fn extruded_deck() -> Presentation { + let mk = |id: u32, x: f64, preset: &str, raw: &str| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 170.0, 170.0, 130.0)), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + effects: Some(deckcraft_model::Effects { raw3d: Some(raw.into()), ..Default::default() }), + ..Default::default() + }; + deck_with(vec![ + mk( + 700, + 40.0, + "rect", + r#""#, + ), + mk( + 701, + 280.0, + "ellipse", + r#""#, + ), + mk( + 702, + 520.0, + "star5", + r#""#, + ), + mk( + 703, + 760.0, + "rect", + r#""#, + ), + ]) +} + +#[test] +fn contours_band_the_outline_in_their_colour() { + let img = render_slide(&extruded_deck(), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let n = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| { + let mut k = 0; + for y in 0..img.height { + for x in x0..x1.min(img.width) { + let i = ((y * img.width + x) * 4) as usize; + if img.pixels.get(i..i + 4).and_then(|p| <[u8; 4]>::try_from(p).ok()).is_some_and(f) { + k += 1; + } + } + } + k + }; + // The ellipse's yellow contour and the flat rectangle's green one. + assert!(n(260, 480, &|p| p[0] > 200 && (150..230).contains(&p[1]) && p[2] < 80) > 200); + assert!(n(740, 960, &|p| p[0] < 80 && p[1] > 120 && p[2] < 120) > 300); +} + +#[test] +fn extrusions_show_their_sides_under_a_turned_camera() { + let p = extruded_deck(); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(path) = std::env::var("DECKCRAFT_PNG_OUT") { + let _ = std::fs::write(path, img.to_png()); + } + let count = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| { + (x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count() + }; + // The red sides of the ellipse; the dark accent-1 sides of the cube; none for a front camera. + assert!(count(250, 500, &|c| c[0] > 100 && c[1] < 40 && c[2] < 40) > 300, "red extrusion"); + let flat = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let ink = |img: &Image, x0: u32, x1: u32| { + (x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y) != [255, 255, 255, 255]).count() + }; + let mut no_depth = extruded_deck(); + for sh in &mut std::sync::Arc::make_mut(&mut no_depth.slides[0]).shapes { + if let Some(fx) = sh.effects.as_mut() { + fx.raw3d = fx.raw3d.as_ref().map(|r| r.replace(" extrusionH=", " x=")); + } + } + let thin = render_slide(&no_depth, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert!(ink(&img, 0, 260) > ink(&thin, 0, 260) + 1000, "the cube's sides add ink"); + assert_eq!(ink(&img, 740, 960), ink(&thin, 740, 960), "a front camera shows no sides"); + assert_eq!(ink(&flat, 0, 960), 0); +} + +#[test] +fn table_rows_below_the_slide_are_not_laid_out_and_the_slide_looks_the_same() { + // A fuzzed deck's 200 x 200 table of long text took 70 s to draw, most of it off the slide. + let table = |rows: usize| { + let mut t = Table::new(rows, 2, 400.0, 30.0); + for r in 0..rows { + for c in 0..2 { + t.cell_mut(r, c).unwrap().text = TextBody::from_text(&format!("Row {r} col {c}")); + } + } + Shape { id: ShapeId(611), xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 30.0 * rows as f64)), kind: ShapeKind::Table(t), ..Default::default() } + }; + let opts = RenderOpts { scale: 0.5, ..Default::default() }; + crate::table::TEXT_CELLS_DRAWN.with(|n| n.set(0)); + let big = render_slide(&deck_with(vec![table(500)]), 0, &opts); + let drawn = crate::table::TEXT_CELLS_DRAWN.with(|n| n.get()); + assert!(drawn > 2 * 10 && drawn < 2 * 40, "{drawn} cells drawn"); + // 30 rows already run past the slide's bottom: the same picture. + let small = render_slide(&deck_with(vec![table(30)]), 0, &opts); + assert_eq!((big.width, big.height), (small.width, small.height)); + let diff = (0..big.height).flat_map(|y| (0..big.width).map(move |x| (x, y))).filter(|&(x, y)| big.pixel(x, y) != small.pixel(x, y)).count(); + assert_eq!(diff, 0); +} + +#[test] +fn office_2016_charts_draw_their_own_layouts() { + let chart = |kind: ChartType, vals: &[f64], subtotals: Vec| { + let c = Chart { + kind, + title: None, + legend: None, + gridlines: false, + subtotals, + categories: (0..vals.len()).map(|i| format!("C{i}")).collect(), + series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + ..Default::default() + }; + let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let ink = |img: &crate::Image, x: u32, y: u32| { + let p = img.pixel(x, y); + p[0] < 235 || p[1] < 235 || p[2] < 235 + }; + // Waterfall: the decrease floats (nothing under it near the axis), the total stands on it. + let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]); + let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500); + let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32; + assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis"); + assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis"); + assert!(ink(&img, band(1.0), 100), "the increase bar is missing"); + // Funnel, treemap and sunburst fill the middle of the chart. + for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] { + let img = chart(kind, &[50.0, 30.0, 20.0], vec![]); + let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count(); + assert!(covered > 1000, "{kind:?}: {covered} samples inked"); + } +} + +/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and +/// box and whisker charts draw a box per group and series. +#[test] +fn histogram_pareto_and_box_charts_draw() { + let shot = |c: Chart| { + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let inked = |img: &crate::Image| { + (0..540) + .step_by(6) + .flat_map(|y| (0..960).step_by(6).map(move |x| (x, y))) + .filter(|&(x, y)| { + let p = img.pixel(x, y); + p[0] < 200 || p[1] < 200 || p[2] < 200 + }) + .count() + }; + let mut empty = Chart::sample(ChartType::Histogram); + empty.series[0].values.clear(); + let blank = inked(&shot(empty)); + for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] { + let img = shot(Chart::sample(kind)); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png()); + } + let n = inked(&img); + assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})"); + } +} + +/// Multi-level treemaps and sunbursts draw their groups: the treemap's group banners and the +/// sunburst's inner ring of groups, coloured by top-level group. +#[test] +fn multilevel_treemap_and_sunburst_draw_their_groups() { + for kind in [ChartType::Treemap, ChartType::Sunburst] { + let c = Chart::sample(kind); + let sh = Shape { id: ShapeId(623), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}-levels.png"), img.to_png()); + } + // Distinct colours: one hue per top-level group plus lighter children. + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(8) { + for x in (40..920).step_by(8) { + let p = img.pixel(x, y); + let c = [p[0] / 16, p[1] / 16, p[2] / 16]; + if c != [15, 15, 15] && !seen.contains(&c) { + seen.push(c); + } + } + } + assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len()); + } +} + +/// A region map without drawable geography falls back to coloured tiles on the value scale. +#[test] +fn region_map_draws_tiles_on_its_colour_scale() { + let names = ["Brest", "Vitebsk", "Gomel", "Grodno", "Minsk", "Mogilev", "Minsk City"]; + let vals = [3.1, 2.2, 2.9, 4.8, 6.0, 1.4, 9.5]; + let c = Chart { + kind: ChartType::RegionMap, + title: Some("Sales by region".into()), + categories: names.iter().map(|s| s.to_string()).collect(), + series: vec![deckcraft_model::chart::Series { name: "Sales".into(), values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + data_labels: true, + ..Default::default() + }; + let sh = Shape { id: ShapeId(624), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/RegionMap-tiles.png"), img.to_png()); + } + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(6) { + for x in (40..920).step_by(6) { + let p = img.pixel(x, y); + let c = [p[0] / 16, p[1] / 16, p[2] / 16]; + if c != [15, 15, 15] && !seen.contains(&c) { + seen.push(c); + } + } + } + assert!(seen.len() >= 5, "{} colours", seen.len()); +} + +/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo +/// chart draws its line series over its columns. +#[test] +fn radar_stock_and_combo_draw_their_own_layouts() { + let mut ohlc = Chart::sample(ChartType::Stock); + ohlc.series.insert( + 0, + deckcraft_model::chart::Series { + name: "Open".into(), + values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(), + ..Default::default() + }, + ); + ohlc.up_down_bars = true; + let charts = [ + ("Radar", Chart::sample(ChartType::Radar)), + ("FilledRadar", Chart::sample(ChartType::FilledRadar)), + ("Stock", Chart::sample(ChartType::Stock)), + ("StockOHLC", ohlc), + ("Combo", Chart::sample(ChartType::Combo)), + ]; + for (name, c) in charts { + let sh = Shape { + id: ShapeId(625), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Chart(Box::new(c.clone())), + ..Default::default() + }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{name}.png"), img.to_png()); + } + let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255]; + match name { + // The radar's top spoke runs straight up from the centre; a line chart has nothing there. + "Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"), + // No series lines: well right of the first category there is only gridline grey. + "Stock" | "StockOHLC" => { + let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| { + let p = img.pixel(x, y); + p[2] > p[0].saturating_add(60) + }); + assert!(!coloured || name == "Stock", "{name}: series drawn as lines"); + } + _ => {} + } + } +} + +#[test] +fn charts_in_degenerate_frames_draw_without_panicking() { + // A chart frame squashed to no width (cx="0" in a generated file) made the value labels' + // gutter clamp panic (its upper bound, a share of the width, fell below its lower bound). + // Every chart type and legend position must survive any frame size. + let p = Presentation::default(); + let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap(); + let kinds = [ + ChartType::Column, + ChartType::StackedColumn, + ChartType::PercentColumn, + ChartType::Bar, + ChartType::StackedBar, + ChartType::PercentBar, + ChartType::Line, + ChartType::LineMarkers, + ChartType::StackedLine, + ChartType::Area, + ChartType::StackedArea, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Bubble, + ChartType::Radar, + ChartType::FilledRadar, + ChartType::Stock, + ChartType::Surface, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Treemap, + ChartType::Sunburst, + ChartType::Combo, + ]; + let mut ctx = vello_cpu::RenderContext::new(64, 64); + for k in kinds { + for (w, h) in [(0.0, 403.0), (403.0, 0.0), (0.0, 0.0), (2.0, 400.0), (400.0, 2.0), (12.0, 12.0)] { + for legend in [None, Some("b"), Some("t"), Some("r"), Some("l"), Some("tr")] { + let mut c = Chart::sample(k); + c.legend = legend.map(String::from); + crate::chart::draw(&mut ctx, &rctx, &c, kurbo::Affine::IDENTITY, w, h); + } + } + } +} + +/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing +/// without them). +#[test] +fn render_an_emf_file_to_look_at() { + let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return }; + let Ok(bytes) = std::fs::read(&src) else { return }; + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes).expect("media"); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let _ = std::fs::write(out, img.to_png()); +} + +#[test] +fn single_series_column_chart_varies_colours_by_point() { + let render = |vary: bool| { + let mut c = Chart::sample(ChartType::Column); + c.series.truncate(1); + c.vary_colors = vary; + let sh = + Shape { id: ShapeId(800), xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }).pixels + }; + assert_ne!(render(true), render(false)); +} diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 7e7f0eac..77fe87ac 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -265,7 +265,7 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { fn picture_deck(line: Option) -> Presentation { let mut p = Presentation::default(); - let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])); + let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])).expect("media"); let sh = Shape { id: ShapeId(15), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), @@ -399,7 +399,7 @@ fn half_red_half_blue() -> Vec { fn tiled(sx: f64, flip: &str, align: &str) -> Image { use deckcraft_model::style::{PictureFill, PictureMode}; let mut p = Presentation::default(); - let media = p.add_media("t.png", "image/png", half_red_half_blue()); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() }; let sh = Shape { id: ShapeId(21), @@ -606,7 +606,7 @@ fn a_large_pictures_shadow_is_whole() { let mut counts = vec![]; for side in [8u32, 400] { let mut p = Presentation::default(); - let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255])); + let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255])).expect("media"); let mut fx = offset_shadow(); if let Some(o) = &mut fx.outer_shadow { o.blur = 8.0; @@ -690,7 +690,7 @@ fn an_upright_picture_fill_stays_upright_in_a_turned_shape() { let blue = Rgba::rgb(0, 0, 255); for upright in [false, true] { let mut p = Presentation::default(); - let media = p.add_media("t.png", "image/png", half_red_half_blue()); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); // Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so // covering the same square). let sh = Shape { @@ -744,3 +744,31 @@ fn a_3d_shapes_face_is_lit_even_without_a_bevel() { let img = render(vec![shape(r#""#)]); assert!(close(img.pixel(175, 150), base, 1)); } + +#[test] +fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() { + // Found by fuzzing the pptx round trip: a 1 EMU `custDash` on a 2147483647 EMU square asked + // the stroker for hundreds of thousands of dashes and aborted on a 7.5 GB allocation. + let sh = Shape { + id: ShapeId(12), + xfrm: Some(Xfrm::new(0.0, 0.0, 169_093.0, 169_093.0)), + geom: Geom::preset("rect"), + line: Some(Line { + fill: solid(RED), + width: Some(0.0001), + dash: Some(deckcraft_model::style::Dash::Custom { pattern: vec![(1.0, 1.0)] }), + ..Default::default() + }), + ..Default::default() + }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 0.25, ..Default::default() }); + assert!(img.width > 0); + // The pattern is scaled up only past the limit; ordinary lines keep theirs. + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((100.0, 0.0)); + assert_eq!(limit_dash_count(&p, vec![1.0, 1.0]), vec![1.0, 1.0]); + p.line_to((4.0e6, 0.0)); + let pat = limit_dash_count(&p, vec![1.0, 1.0]); + assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}"); +} diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs index 3be4db3e..ac9b3afd 100644 --- a/crates/text/src/bidi.rs +++ b/crates/text/src/bidi.rs @@ -102,7 +102,9 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo } } // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next - // to them, belong to the number. + // to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, + // computed once so a long run of currency signs costs linear work, not a scan per sign. + let after_et = next_non_et(&cls); for i in 0..n { if !rl(i) || dir.get(i) != Some(&Dir::Open) { continue; @@ -111,7 +113,7 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); let joined = match c { ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), - ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))), + ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), _ => false, }; if joined && let Some(x) = dir.get_mut(i) { @@ -135,9 +137,21 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo Some(Dir::R | Dir::Num) => Some(Dir::R), _ => None, }; + // Resolving a span only changes characters before the scan position, so the nearest strong + // direction after a span can be computed once up front, and the one before it is carried along: + // linear work however many neutral spans the paragraph has. + let mut next_strong: Vec> = vec![None; n + 1]; + for j in (0..n).rev() { + let here = strong_at(&dir, j).or(next_strong.get(j + 1).copied().flatten()); + if let Some(x) = next_strong.get_mut(j) { + *x = here; + } + } + let mut last_strong: Option

= None; let mut i = 0; while i < n { if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { + last_strong = strong_at(&dir, i).or(last_strong); i += 1; continue; } @@ -145,12 +159,15 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { i += 1; } - let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos); - let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos); + let before = last_strong.unwrap_or(sos); + let after = next_strong.get(i).copied().flatten().unwrap_or(sos); let d = if before == after { before } else { Dir::R }; for x in dir.get_mut(start..i).into_iter().flatten() { *x = d; } + if start < i { + last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + } } // Spaces in left-to-right runs follow the character before them. let mut prev = Dir::L; @@ -170,6 +187,21 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo .collect() } +/// For each index, the first later index whose class is not ET (`None` when only ET follows). +fn next_non_et(cls: &[BidiClass]) -> Vec> { + let mut out = vec![None; cls.len()]; + let mut next = None; + for i in (0..cls.len()).rev() { + if let Some(x) = out.get_mut(i) { + *x = next; + } + if cls.get(i) != Some(&ET) { + next = Some(i); + } + } + out +} + /// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a /// segment/paragraph separator. fn l1_whitespace(c: BidiClass) -> bool { @@ -326,4 +358,182 @@ mod tests { assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]); assert_eq!(visual_order(&[125, 125]), vec![1, 0]); } + + /// The pre-#403 `levels_lang`, kept as the reference the linear version must match. + fn reference_levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[bool]) -> Vec { + let base = u8::from(rtl); + let any_rtl_lang = rtl_lang.iter().any(|r| *r); + if !rtl && !any_rtl_lang && !needs_bidi(chars) { + return vec![0; chars.len()]; + } + let classes: Vec = chars.iter().map(|c| bidi_class(*c)).collect(); + if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) { + return levels(text, chars, rtl); + } + let n = chars.len(); + let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false); + let sos = if rtl { Dir::R } else { Dir::L }; + // W1: combining marks take the class of the character they sit on. + let mut cls = classes; + for i in 0..n { + if cls.get(i) == Some(&NSM) { + let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; + if let Some(c) = cls.get_mut(i) { + *c = prev; + } + } + } + let mut dir = vec![Dir::Open; n]; + // Strong letters and digits. + let mut last_strong: Option = None; + for i in 0..n { + let c = cls.get(i).copied().unwrap_or(ON); + let d = match c { + L => Dir::L, + R | AL => Dir::R, + AN => Dir::Num, + EN if !rl(i) => Dir::L, + // W7: European digits after left-to-right text are left to right. + EN if last_strong == Some(L) => Dir::L, + EN => Dir::Num, + _ => Dir::Open, + }; + if matches!(c, L | R | AL) { + last_strong = Some(c); + } + if let Some(x) = dir.get_mut(i) { + *x = d; + } + } + // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next + // to them, belong to the number. + for i in 0..n { + if !rl(i) || dir.get(i) != Some(&Dir::Open) { + continue; + } + let c = cls.get(i).copied().unwrap_or(ON); + let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); + let joined = match c { + ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), + ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))), + _ => false, + }; + if joined && let Some(x) = dir.get_mut(i) { + *x = Dir::Num; + } + } + // Punctuation in left-to-right runs is left to right. + for i in 0..n { + if !rl(i) + && dir.get(i) == Some(&Dir::Open) + && !matches!(cls.get(i), Some(WS | S)) + && let Some(x) = dir.get_mut(i) + { + *x = Dir::L; + } + } + // Neutrals in right-to-left runs: the direction around them when both sides agree, else + // right to left. Unresolved spaces of left-to-right runs are looked through. + let strong_at = |dir: &[Dir], j: usize| match dir.get(j) { + Some(Dir::L) => Some(Dir::L), + Some(Dir::R | Dir::Num) => Some(Dir::R), + _ => None, + }; + let mut i = 0; + while i < n { + if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { + i += 1; + continue; + } + let start = i; + while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { + i += 1; + } + let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos); + let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos); + let d = if before == after { before } else { Dir::R }; + for x in dir.get_mut(start..i).into_iter().flatten() { + *x = d; + } + } + // Spaces in left-to-right runs follow the character before them. + let mut prev = Dir::L; + for d in dir.iter_mut() { + if *d == Dir::Open { + *d = prev; + } + prev = if *d == Dir::Num { Dir::R } else { *d }; + } + dir.iter() + .map(|d| match (d, base % 2 == 1) { + (Dir::L, false) | (Dir::R, true) => base, + (Dir::L, true) | (Dir::R, false) | (Dir::Open, _) => base + 1, + (Dir::Num, false) => base + 2, + (Dir::Num, true) => base + 1, + }) + .collect() + } + + fn lang(s: &str, rtl: bool, rtl_lang: &[bool]) -> Vec { + let chars: Vec = s.chars().collect(); + levels_lang(s, &chars, rtl, rtl_lang) + } + + #[test] + fn next_non_et_points_past_each_et_run() { + let cls: Vec = "a$$1%".chars().map(bidi_class).collect(); + assert_eq!(next_non_et(&cls), vec![Some(3), Some(3), Some(3), None, None]); + } + + #[test] + fn currency_signs_join_numbers_on_either_side_in_rtl_runs() { + // Hebrew word, then "$$5" and "5%%": the signs travel with the digits. + let s = "שלום $$5 5%% $$"; + let n = s.chars().count(); + let l = lang(s, true, &vec![true; n]); + let chars: Vec = s.chars().collect(); + let num = l[chars.iter().position(|c| *c == '5').unwrap()]; + assert_eq!(&l[5..8], &[num; 3], "{l:?}"); + assert_eq!(&l[9..12], &[num; 3], "{l:?}"); + // Signs with no number next to them stay with the right-to-left text. + assert_eq!(l[13] % 2, 1); + assert_ne!(l[13], num); + } + + #[test] + fn long_currency_and_neutral_runs_are_linear() { + // Quadratic resolution took minutes on these; linear finishes at once even unoptimised. + let n = 200_000; + let dollars: String = std::iter::once('ש').chain(std::iter::repeat_n('$', n)).collect(); + let l = lang(&dollars, true, &vec![true; n + 1]); + assert!(l.iter().all(|v| v % 2 == 1)); + // Alternating right-to-left-language neutrals and left-to-right-language spaces: many spans. + let s: String = std::iter::repeat_n("! ", n / 2).collect(); + let langs: Vec = (0..n).map(|i| i % 2 == 0).collect(); + assert_eq!(lang(&s, true, &langs).len(), n); + } + + #[test] + fn matches_the_reference_on_random_mixed_text() { + let alphabet: Vec = "aZש ب$%#+-,.:1٣ ()!\u{300}".chars().collect(); + let mut seed = 0x2545_f491_u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for _ in 0..4000 { + let len = (next() % 24) as usize; + let chars: Vec = (0..len).map(|_| alphabet[next() as usize % alphabet.len()]).collect(); + let text: String = chars.iter().collect(); + let langs: Vec = (0..len).map(|_| next() % 3 != 0).collect(); + let rtl = next() % 2 == 0; + assert_eq!( + levels_lang(&text, &chars, rtl, &langs), + reference_levels_lang(&text, &chars, rtl, &langs), + "{text:?} rtl={rtl} langs={langs:?}" + ); + } + } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d6bc66bf..840b7834 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -523,10 +523,12 @@ fn face_chain(ctx: &Ctx, st: &Style, slot: FontSlot, script: Option) chain } -fn spacing_pts(s: Option, size: f64, reduce: f64) -> f64 { +/// Space before or after a paragraph, points. Shrink-on-overflow's line spacing reduction +/// doesn't apply to it (PowerPoint keeps it). +fn spacing_pts(s: Option, size: f64) -> f64 { match s { - Some(Spacing::Pct(p)) => p * size * 1.2 * (1.0 - reduce), - Some(Spacing::Pts(v)) => v * (1.0 - reduce), + Some(Spacing::Pct(p)) => p * size * 1.2, + Some(Spacing::Pts(v)) => v, None => 0.0, } } @@ -1168,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option { Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•'))) } -fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { +/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character. +pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); if f.starts_with("webdings") { @@ -1534,20 +1537,47 @@ fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 (next - x).max(1.0) } +#[cfg(test)] +thread_local! { + /// How many times `layout_scaled` ran on this thread (tests count autofit's passes). + pub(crate) static LAYOUT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Lay out a shape's text body. pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { let bp = resolve::body(ctx, shape); let autofit = bp.autofit.unwrap_or(AutoFit::None); - let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0); + // Shrink on overflow starts from the scale the file was saved with (`fontScale`, + // `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it. + let (scale0, reduce0) = match (autofit, opts.no_shrink) { + (AutoFit::Shrink { font_scale, line_reduction }, false) => ( + if font_scale.is_finite() { font_scale.clamp(0.1, 1.0) } else { 1.0 }, + if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 }, + ), + _ => (1.0, 0.0), + }; + let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0); + // A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it + // changes (and editing resets the saved scale), not when a file opens, even where our + // metrics would call it overflowing (trailing empty paragraphs, substituted fonts). + let saved = scale0 < 1.0 || reduce0 > 0.0; if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) && l.overflow + && !saved { + // Text that overflows even at the smallest scale gets that scale: every larger one + // overflows too, so searching would only lay it out a dozen more times (seconds for a + // body of tens of thousands of paragraphs, on every redraw of the slide). + let smallest = layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2); + if smallest.overflow { + return smallest; + } // Binary search the largest scale that fits (steps like 90%, 80%… with line reduction). - let (mut lo, mut hi) = (0.1_f64, 1.0_f64); - let mut best = None; + let (mut lo, mut hi) = (0.1_f64, scale0); + let mut best = Some(smallest); for _ in 0..12 { let mid = (lo + hi) / 2.0; - let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2); + let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2).max(reduce0); let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); if t.overflow { hi = mid; @@ -1556,13 +1586,17 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay best = Some(t); } } - l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2)); + if let Some(b) = best { + l = b; + } } l } #[allow(clippy::too_many_arguments)] fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text @@ -1605,6 +1639,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let mut numbering: Vec = vec![0; 9]; let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![]; let mut prev_after = 0.0; + // How far the last line's full pitch reaches past the text (exact spacing; see below). + let mut tail = 0.0; + let first_last = bp.space_first_last.unwrap_or(false); let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); for (pi, para) in body.paragraphs.iter().enumerate() { let lvl = para.level.min(8) as usize; @@ -1648,7 +1685,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let first_w = (col_w - first_x - mr).max(1.0); let rest_w = (col_w - rest_x - mr).max(1.0); let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); - let before = if pi == 0 { 0.0 } else { spacing_pts(props.space_before, p.lead_size, reduce) }; + // The first paragraph's space before counts only when the body says so. + let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; y += before.max(0.0) + prev_after; let first_line_idx = all_lines.len(); for (li, rl) in raw.iter().enumerate() { @@ -1675,13 +1713,19 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } let natural = (asc + desc).max(size * 1.0); let lh = match props.line_spacing { - Some(Spacing::Pct(f)) => natural * f.max(0.0) * (1.0 - reduce), + // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint + // applies `lnSpcReduction`. + Some(Spacing::Pct(f)) => natural * (f - reduce).max(f * 0.1).max(0.0), Some(Spacing::Pts(v)) => v * (1.0 - reduce), None => natural * (1.0 - reduce), }; let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; let top = y; - let baseline = top + asc * ratio; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; y += lh; let x0 = if li == 0 { first_x } else { rest_x }; let avail = (col_w - x0 - mr).max(0.0); @@ -1824,9 +1868,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts )); } out.para_lines.push((first_line_idx, raw.len())); - prev_after = spacing_pts(props.space_after, p.lead_size, reduce).max(0.0); + prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); } - let total = y; + // The text ends at the last line's descent under exact spacing; the last paragraph's space + // after counts only when the body says so. + let total = (y - tail).max(0.0) + if first_last { prev_after } else { 0.0 }; out.content_height = total; // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the // first column on the right). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 663834b8..9eb20142 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -7,7 +7,7 @@ use deckcraft_model::{LayoutType, Presentation, Xfrm}; fn setup(text: &str, w: f64, h: f64) -> (Presentation, Shape) { let mut p = Presentation::default(); let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let mut s = defaults::new_slide(&mut p, lay); + let mut s = defaults::new_slide(&mut p, lay).expect("slide"); let mut body = s.shapes.remove(1); body.xfrm = Some(Xfrm::new(0.0, 0.0, w, h)); body.text = Some(TextBody::from_text(text)); @@ -181,6 +181,120 @@ fn shrink_on_overflow() { assert!(!l.overflow || l.font_scale <= 0.11); } +#[test] +fn saved_shrink_scale_is_drawn() { + // A file saved with fontScale 62.5% and lnSpcReduction 20% draws at that scale even though + // the text would fit at full size, as PowerPoint draws it. + let (p, mut sh) = setup("Short", 600.0, 300.0); + sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }); + let l = lay(&p, &sh, 600.0, 300.0); + assert_eq!((l.font_scale, l.line_reduction), (0.625, 0.2)); + // Text our metrics find overflowing at the saved scale is still drawn at it, as PowerPoint + // draws it until the text changes; with no saved scale it shrinks to fit. + let text = (0..30).map(|i| format!("Line number {i}")).collect::>().join("\n"); + let (p, mut sh) = setup(&text, 600.0, 200.0); + sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.9, line_reduction: 0.1 }); + let l = lay(&p, &sh, 600.0, 200.0); + assert_eq!((l.font_scale, l.line_reduction), (0.9, 0.1)); + sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + let l = lay(&p, &sh, 600.0, 200.0); + assert!(l.font_scale < 0.9, "{}", l.font_scale); + // Nonsense saved values are ignored. + let (p, mut sh) = setup("Short", 600.0, 300.0); + sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: f64::NAN, line_reduction: 5.0 }); + let l = lay(&p, &sh, 600.0, 300.0); + assert_eq!((l.font_scale, l.line_reduction), (1.0, 0.2)); +} + +#[test] +fn exact_line_spacing_ends_at_the_last_descent() { + // 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text): + // the baseline at 80 % of the pitch, the text ending at the last line's descent. + let (p, mut sh) = setup("One\nTwo", 600.0, 300.0); + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.line_spacing = Some(Spacing::Pts(26.59)); + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.size = Some(12.0); + } + } + let l = lay(&p, &sh, 600.0, 300.0); + let first = &l.lines[0]; + assert!((first.baseline - first.top - 26.59 * 0.8).abs() < 0.01, "{:?}", first.baseline - first.top); + // The text ends at the last line's descent: above its full pitch by 20 % of it less the + // descent (~2.3 pt for 12 pt type). + let short = (l.lines[1].bottom - l.lines[0].top) - l.content_height; + assert!(short > 1.0 && short < 26.59 * 0.2, "{short}"); + // Proportional spacing keeps full pitches. + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.line_spacing = Some(Spacing::Pct(1.0)); + } + let l = lay(&p, &sh, 600.0, 300.0); + assert!((l.content_height - (l.lines[1].bottom - l.lines[0].top)).abs() < 0.01); +} + +#[test] +fn line_spacing_reduction_comes_off_the_percentage() { + // 90 % line spacing and 10 pt before paragraphs, shrunk with a 20 % line spacing reduction: + // PowerPoint draws 70 % lines and keeps the 10 pt. + let (p, mut sh) = setup("One\nTwo\nThree", 600.0, 400.0); + for (i, para) in sh.text.as_mut().unwrap().paragraphs.iter_mut().enumerate() { + para.props.line_spacing = Some(Spacing::Pct(0.9)); + para.props.space_before = Some(Spacing::Pts(10.0)); + para.props.bullet = Some(Bullet::None); + if i == 0 { + para.runs[0].text.push_str(" wraps onto a second line because this paragraph is long enough to wrap in a narrow box"); + } + } + let ctx = Ctx::for_slide(&p, &p.slides[0]).unwrap(); + let body = sh.text.clone().unwrap(); + let bp = resolve::body(&ctx, &sh); + let opts = Opts { rect: Rect::new(0.0, 0.0, 200.0, 400.0), fields: &NoFields, prompt_color: None, no_shrink: true }; + let full = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.0); + let reduced = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.2); + let pitch = |l: &TextLayout| l.lines[1].top - l.lines[0].top; + // Lines within the first paragraph: 0.7 / 0.9 of the full pitch. + assert!(full.lines[1].para == 0 && reduced.lines[1].para == 0); + assert!((pitch(&reduced) / pitch(&full) - 0.7 / 0.9).abs() < 0.005, "{} {}", pitch(&reduced), pitch(&full)); + // The space before the next paragraph is the same 10 pt. + let gap = |l: &TextLayout| { + let k = l.lines.iter().position(|x| x.para == 1).unwrap(); + l.lines[k].top - l.lines[k - 1].bottom + }; + assert!((gap(&full) - 10.0).abs() < 1e-6 && (gap(&reduced) - 10.0).abs() < 1e-6, "{} {}", gap(&full), gap(&reduced)); +} + +#[test] +fn shrink_gives_up_at_once_when_even_the_smallest_scale_overflows() { + // Found by fuzzing: a tiny box with 20,000 paragraphs and shrink on overflow took 15 s to + // open, laying out the whole body 13 times in a search that could never fit. + let text = (0..400).map(|i| format!("Line number {i}")).collect::>().join("\n"); + let (p, sh) = setup(&text, 100.0, 20.0); + crate::LAYOUT_PASSES.with(|n| n.set(0)); + let l = lay(&p, &sh, 100.0, 20.0); + assert!(l.overflow && l.font_scale <= 0.11, "scale {}", l.font_scale); + assert!(crate::LAYOUT_PASSES.with(|n| n.get()) <= 2, "{} passes", crate::LAYOUT_PASSES.with(|n| n.get())); + // Text that fits somewhere in the range still gets the search. + let (p, sh) = setup(&(0..12).map(|i| format!("Line number {i}")).collect::>().join("\n"), 600.0, 200.0); + let l = lay(&p, &sh, 600.0, 200.0); + assert!(l.font_scale > 0.11 && l.font_scale < 1.0 && !l.overflow, "scale {} overflow {}", l.font_scale, l.overflow); +} + +#[test] +fn space_before_the_first_paragraph_with_spc_first_last_para() { + // 10 pt before every paragraph: skipped on the first one, unless the body asks for it. + let (p, mut sh) = setup("One\nTwo", 600.0, 300.0); + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.space_before = Some(Spacing::Pts(10.0)); + para.props.space_after = Some(Spacing::Pts(6.0)); + } + let plain = lay(&p, &sh, 600.0, 300.0); + sh.text.as_mut().unwrap().body.space_first_last = Some(true); + let both = lay(&p, &sh, 600.0, 300.0); + assert!((both.lines[0].top - plain.lines[0].top - 10.0).abs() < 1e-6); + assert!((both.content_height - plain.content_height - 16.0).abs() < 1e-6); +} + #[test] fn caret_and_hit_roundtrip() { let (p, sh) = setup("Hello world", 600.0, 100.0); diff --git a/crates/ui-egui/src/animpane_tests.rs b/crates/ui-egui/src/animpane_tests.rs new file mode 100644 index 00000000..7fe2d875 --- /dev/null +++ b/crates/ui-egui/src/animpane_tests.rs @@ -0,0 +1,90 @@ +//! The Animation Pane's advanced timeline driven with the mouse: dragging a bar changes the +//! delay, dragging its right edge changes the duration, each one undoable command. + +use deckcraft_engine::Session; +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// A blank slide with two shapes: Fade on click (1 s), then Zoom after previous (2 s). +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + for (i, (effect, start, dur)) in [("fade", "onClick", 1000), ("zoom", "afterPrevious", 2000)].iter().enumerate() { + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0 + 200.0 * i as f64, 80, 150, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("animation.add", &json!({"effect": effect, "start": start, "duration": dur})).unwrap(); + } + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("animation".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn timing(h: &Harness<'_, SlideApp>, i: usize) -> (u32, u32) { + let a = &h.state().session.active().unwrap().current_slide().unwrap().animations[i]; + (a.delay_ms, a.duration_ms) +} + +fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +#[test] +fn after_previous_bar_starts_where_the_first_ends() { + let h = harness(); + let a = h.get_by_label("Timing bar 1").rect(); + let b = h.get_by_label("Timing bar 2").rect(); + assert!((b.left() - a.right()).abs() < 1.5, "{a:?} {b:?}"); + assert!((b.width() - 2.0 * a.width()).abs() < 1.5, "2 s bar twice the 1 s bar"); +} + +#[test] +fn dragging_a_bar_sets_the_delay_and_undoes() { + let mut h = harness(); + let a = h.get_by_label("Timing bar 1").rect(); + // One second is the width of bar 1. + drag(&mut h, a.center(), a.center() + egui::vec2(a.width() / 2.0, 0.0)); + assert_eq!(timing(&h, 0), (500, 1000)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(timing(&h, 0), (0, 1000)); +} + +#[test] +fn dragging_the_edge_sets_the_duration() { + let mut h = harness(); + let b = h.get_by_label("Timing bar 2").rect(); + let one_sec = b.width() / 2.0; + let edge = h.get_by_label("Duration handle 2").rect().center(); + drag(&mut h, edge, edge - egui::vec2(one_sec, 0.0)); + assert_eq!(timing(&h, 1), (0, 1000)); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let mut h = harness(); + h.run_steps(2); + let img = h.render().unwrap(); + img.save("/tmp/animpane.png").unwrap(); +} diff --git a/crates/ui-egui/src/animtimeline.rs b/crates/ui-egui/src/animtimeline.rs new file mode 100644 index 00000000..bdd1a1dc --- /dev/null +++ b/crates/ui-egui/src/animtimeline.rs @@ -0,0 +1,150 @@ +//! Animation Pane advanced timeline: bar geometry and drag-to-retime maths. +//! +//! Pure functions so the layout and the drag results are unit-tested without a frame. Times +//! come from [`deckcraft_anim::Timeline`], the same schedule the slide show plays. + +use deckcraft_anim::{EffectTiming, Sequence}; + +/// Drags snap to this step (milliseconds), like PowerPoint's 0.05 s granularity. +pub const SNAP_MS: u32 = 50; +/// Shortest duration a drag can produce (milliseconds). +pub const MIN_DURATION_MS: u32 = 50; +/// Upper bound for delay and duration, matching `animation.timing`'s clamp. +pub const MAX_MS: u32 = 600_000; +/// Pixels per second are kept in this range whatever the pane width. +const MIN_PPS: f32 = 8.0; +const MAX_PPS: f32 = 120.0; + +/// One effect row of the timeline (first paragraph of a by-paragraph build). +#[derive(Clone, Debug, PartialEq)] +pub struct Bar { + /// Index into `slide.animations`. + pub anim: usize, + /// Click step in the main sequence, or `None` for a triggered sequence. + pub step: Option, + /// Start within the step, delay included (seconds). + pub start: f64, + /// Start to end, all repeats included (seconds). + pub span: f64, +} + +/// One bar per animation, in `slide.animations` order; animations without timing get `None`. +pub fn bars(count: usize, effects: &[EffectTiming]) -> Vec> { + let mut out: Vec> = vec![None; count]; + for e in effects { + let Some(slot) = out.get_mut(e.anim) else { continue }; + if slot.is_some() { + continue; + } + let start = if e.start.is_finite() { e.start.max(0.0) } else { 0.0 }; + let span = if e.span.is_finite() { e.span.max(0.0) } else { 0.0 }; + let step = match e.sequence { + Sequence::Main => Some(e.step), + Sequence::Trigger(_) => None, + }; + *slot = Some(Bar { anim: e.anim, step, start, span }); + } + out +} + +/// Seconds the ruler must show: the longest bar end, at least one second, rounded up. +pub fn ruler_seconds(bars: &[Option]) -> u32 { + let end = bars.iter().flatten().map(|b| b.start + b.span).fold(1.0_f64, f64::max); + let s = end.ceil().min(f64::from(MAX_MS / 1000)); + if s.is_finite() && s >= 1.0 { s as u32 } else { 1 } +} + +/// Pixels per second so `seconds` fit in `width`. +pub fn pixels_per_second(width: f32, seconds: u32) -> f32 { + let secs = seconds.max(1) as f32; + let pps = if width.is_finite() { width / secs } else { MIN_PPS }; + pps.clamp(MIN_PPS, MAX_PPS) +} + +fn snap(ms: f64) -> u32 { + if !ms.is_finite() || ms <= 0.0 { + return 0; + } + let snapped = (ms / f64::from(SNAP_MS)).round() * f64::from(SNAP_MS); + if snapped >= f64::from(MAX_MS) { MAX_MS } else { snapped as u32 } +} + +fn delta_ms(dx: f32, pps: f32) -> f64 { + if !dx.is_finite() || !pps.is_finite() || pps <= 0.0 { 0.0 } else { f64::from(dx) / f64::from(pps) * 1000.0 } +} + +/// New delay after dragging a bar's body `dx` pixels. +pub fn dragged_delay(delay_ms: u32, dx: f32, pps: f32) -> u32 { + snap(f64::from(delay_ms) + delta_ms(dx, pps)) +} + +/// New duration after dragging a bar's right edge `dx` pixels. The bar shows all `repeats` +/// (auto-reverse doubles each), so the edge moves `factor` times as fast as one iteration. +pub fn dragged_duration(duration_ms: u32, dx: f32, pps: f32, factor: f64) -> u32 { + let factor = if factor.is_finite() && factor >= 1.0 { factor } else { 1.0 }; + snap(f64::from(duration_ms) + delta_ms(dx, pps) / factor).max(MIN_DURATION_MS) +} + +/// Tooltip text for a bar: `Start: 0.5s, End: 2.5s`. +pub fn label(bar: &Bar) -> String { + format!("Start: {:.2}s, End: {:.2}s", bar.start, bar.start + bar.span) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{AnimClass, ShapeId}; + + fn eff(anim: usize, step: usize, start: f64, span: f64) -> EffectTiming { + EffectTiming { + anim, + shape: ShapeId(1), + paragraph: None, + sequence: Sequence::Main, + step, + start, + duration: span, + iterations: 1.0, + until_next_click: false, + auto_reverse: false, + rewind: false, + span, + class: AnimClass::Entrance, + } + } + + #[test] + fn first_paragraph_wins_and_missing_are_none() { + let b = bars(3, &[eff(0, 0, 0.0, 0.5), eff(0, 0, 0.5, 0.5), eff(2, 1, f64::NAN, -1.0), eff(9, 0, 0.0, 1.0)]); + assert_eq!(b[0], Some(Bar { anim: 0, step: Some(0), start: 0.0, span: 0.5 })); + assert_eq!(b[1], None); + assert_eq!(b[2], Some(Bar { anim: 2, step: Some(1), start: 0.0, span: 0.0 })); + } + + #[test] + fn ruler_and_scale() { + assert_eq!(ruler_seconds(&[]), 1); + assert_eq!(ruler_seconds(&bars(1, &[eff(0, 0, 1.2, 2.0)])), 4); + assert_eq!(pixels_per_second(200.0, 4), 50.0); + assert_eq!(pixels_per_second(10_000.0, 1), MAX_PPS); + assert_eq!(pixels_per_second(f32::NAN, 0), MIN_PPS); + } + + #[test] + fn drags_snap_and_clamp() { + assert_eq!(dragged_delay(0, 25.0, 50.0), 500); + assert_eq!(dragged_delay(500, -100.0, 50.0), 0); + assert_eq!(dragged_delay(0, 13.0, 50.0), 250); + assert_eq!(dragged_delay(0, f32::INFINITY, 50.0), 0); + assert_eq!(dragged_delay(MAX_MS, 1.0e9, 1.0), MAX_MS); + assert_eq!(dragged_duration(500, 50.0, 50.0, 1.0), 1500); + assert_eq!(dragged_duration(500, 50.0, 50.0, 2.0), 1000); + assert_eq!(dragged_duration(500, -1000.0, 50.0, 1.0), MIN_DURATION_MS); + assert_eq!(dragged_duration(500, 0.0, 0.0, f64::NAN), 500); + } + + #[test] + fn label_text() { + assert_eq!(label(&Bar { anim: 0, step: None, start: 0.5, span: 2.0 }), "Start: 0.50s, End: 2.50s"); + } +} diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/background_tests.rs new file mode 100644 index 00000000..0bb3d229 --- /dev/null +++ b/crates/ui-egui/src/background_tests.rs @@ -0,0 +1,75 @@ +//! Format Background pane: picture transparency, tiling, and Apply to All. + +use deckcraft_engine::Session; +use deckcraft_model::style::PictureMode; +use deckcraft_model::{Background, Fill}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// 1×1 PNG. +const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +fn harness(mut app: SlideApp) -> Harness<'static, SlideApp> { + app.ui.tab = "Design".into(); + app.ui.pane = Some("background".into()); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn picture_mode(h: &Harness<'_, SlideApp>) -> PictureMode { + match &h.state().session.active().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.mode.clone(), + other => panic!("expected a picture background, got {other:?}"), + } +} + +#[test] +fn picture_fill_shows_transparency_and_tiling() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default())); + assert!(h.query_all_by_label("Format Background").next().is_some(), "the pane is open"); + let _ = h.get_by_label("Picture or texture fill"); + assert!(h.query_all_by_label("Transparency").next().is_some()); + h.get_by_label("Tile picture as texture").click(); + h.run_steps(2); + let _ = h.get_by_label("Offset X"); + let _ = h.get_by_label("Scale X"); + let _ = h.get_by_label("Alignment"); + let _ = h.get_by_label("Mirror"); + assert!(matches!(picture_mode(&h), PictureMode::Tile { .. })); +} + +#[test] +fn solid_fill_shows_transparency_without_tile_options() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"color": "accent1"})).unwrap(); + let h = harness(SlideApp::new(s, Services::default())); + assert!(h.query_all_by_label("Transparency").next().is_some()); + let _ = h.get_by_label("Solid fill"); + assert!(h.query_by_label("Tile picture as texture").is_none()); +} + +#[test] +fn apply_to_all_copies_the_picture() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default())); + h.get_by_label("Apply to All").click(); + h.run_steps(2); + let doc = &h.state().session.active().unwrap().doc; + assert!(matches!(doc.slides[1].background, Some(Background::Fill { fill: Fill::Picture(_) }))); + assert_eq!(doc.slides[1].background, doc.slides[0].background); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f1d0fde3..f7bfe253 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -166,6 +166,9 @@ pub struct Backstage { pub print_layout: &'static str, pub print_hidden: bool, pub print_frame: bool, + /// Notes and handouts: a paper id from [`deckcraft_engine::cmd::file::PAPERS`] and its orientation. + pub print_paper: &'static str, + pub print_landscape: bool, pub preview_page: usize, pub export: ExportTask, /// Export › Change File Type / Save As: the chosen type (an [`SAVE_TYPES`] id). @@ -187,6 +190,8 @@ impl Backstage { print_layout: "slides", print_hidden: false, print_frame: true, + print_paper: "letter", + print_landscape: false, preview_page: 0, export: ExportTask::Pdf, file_type: "pptx", @@ -353,6 +358,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } if nav_item(ui, *nav, &bs, index, &t).clicked() { bs.focus = None; + commit_props(app, &bs); match act(app, *nav, bs.page) { None => out.close = true, Some(p) => out.page = Some(p), @@ -367,6 +373,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { for (k, nav) in bottom.iter().enumerate().rev() { if nav_item(ui, *nav, &bs, first_bottom + k, &t).clicked() { bs.focus = None; + commit_props(app, &bs); match act(app, *nav, bs.page) { None => out.close = true, Some(p) => out.page = Some(p), @@ -391,6 +398,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text)); ui.add_space(16.0); } + // Errors raised on a backstage page (a missing recent file, a failed open) would + // otherwise vanish: the status bar only draws once the backstage is closed (#172). + // A real label, not painted text, so screen readers see it too. + if let Some(msg) = app.fresh_status() { + ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent)); + ui.add_space(8.0); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } match bs.page { Page::Home => home(app, ui, &mut bs, &mut out), Page::New => new_page(app, ui, &mut bs), @@ -497,15 +512,14 @@ fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: b } /// The built-in themes and the sample deck, filtered by `query` (after a blank presentation when -/// `blank`); at most `limit` theme tiles. -fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) { +/// `blank`); at most `limit` theme tiles. False when nothing matched. +fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) -> bool { let q = query.trim().to_lowercase(); let themes: Vec = deckcraft_model::theme::builtin_themes().into_iter().filter(|th| q.is_empty() || th.name.to_lowercase().contains(&q)).collect(); let sample = q.is_empty() || "sample deck northern lights".contains(&q); if themes.is_empty() && !sample { - dim(ui, &format!("No themes match “{}”.", query.trim())); - return; + return false; } ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); @@ -531,6 +545,25 @@ fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: } } }); + true +} + +/// Starter decks whose name, summary, theme or a slide title matches `query`, at most `limit` tiles. +fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> bool { + let list = deckcraft_engine::templates::matching(query); + if list.is_empty() { + return false; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + for starter in list.into_iter().take(limit) { + let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); + if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { + let _ = app.run("file.new", json!({"template": starter.name})); + } + } + }); + true } /// File name and folder of a recent path, split as text (nothing on disk is read). @@ -558,6 +591,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { } let width = ui.available_width().min(CONTENT_W); for path in list { + let missing = !app.recent_exists(&path); let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); @@ -565,13 +599,37 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { if resp.hovered() { p.rect_filled(rect, CornerRadius::same(3), t.hover); } - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, t.text, false); - p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), t.text); - p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); - if resp.on_hover_text(&path).clicked() - && let Err(e) = app.open_path(&path) + let ink = if missing { t.text_dim } else { t.text }; + // The row is as wide as the page's content, which a narrow window clips: keep the mark + // and the remove button inside the visible part of the row. + let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); { - app.set_status(e); + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); + p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); + p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); + // A missing file says so before it is clicked (#172). + if missing { + p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + } + } + let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; + // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon + // (a text glyph would need a font that has it). + let x = ui.allocate_rect( + Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), + Sense::click(), + ); + x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent")); + if x.hovered() { + ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); + if x.on_hover_text("Remove from Recent").clicked() { + app.forget_recent(&path); + } + if resp.clicked() { + app.open_path_in_background(&path); } } } @@ -648,6 +706,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) ui.add_space(6.0); new_tiles(app, ui, "", 5, true); ui.add_space(22.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if arrow_link(ui, "More templates") { + out.page = Some(Page::New); + } + }); + }); + ui.add_space(6.0); + template_tiles(app, ui, "", 3); + ui.add_space(22.0); ui.horizontal(|ui| { ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -668,7 +737,7 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); } -/// New: a blank presentation, then every theme with a search box. +/// New: a blank presentation, then every theme and starter template, with a search box. fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let t = Tokens::get(ui.ctx()); if tile(ui, "Blank Presentation", None, true).clicked() { @@ -679,10 +748,20 @@ fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); ui.add_space(14.0); - search(ui, &mut bs.query, "Search for themes", 380.0); + search(ui, &mut bs.query, "Search for themes and templates", 380.0); ui.add_space(14.0); let q = bs.query.clone(); - new_tiles(app, ui, &q, usize::MAX, false); + let themes = new_tiles(app, ui, &q, usize::MAX, false); + let templates = if q.trim().is_empty() || !deckcraft_engine::templates::matching(&q).is_empty() { + ui.add_space(18.0); + heading(ui, "Templates"); + template_tiles(app, ui, &q, usize::MAX) + } else { + false + }; + if !themes && !templates { + dim(ui, &format!("No themes or templates match “{}”.", q.trim())); + } } /// Open: Recent and Browse on the left; the recent presentations (with a search box) on the right. @@ -796,15 +875,16 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let Some(draft) = bs.props.get_mut(i) else { continue }; let l = ui.label(RichText::new(*label).color(t.text_dim)); let id = ui.make_persistent_id(("backstage_prop", *key)); - // Show the presentation's value unless the field is being edited (undo…). - if !ui.ctx().memory(|m| m.has_focus(id)) { - draft.clone_from(&saved); - } let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); if resp.lost_focus() && *draft != saved { + // Commit first: a click on another field takes the focus before this one is drawn. let mut p = serde_json::Map::new(); p.insert((*key).to_string(), json!(draft.clone())); let _ = app.run("file.properties", Value::Object(p)); + } else if !resp.has_focus() && *draft != saved { + // Show the presentation's value unless the field is being edited (undo...). + draft.clone_from(&saved); + ui.ctx().request_repaint(); } ui.end_row(); } @@ -814,6 +894,24 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { }); } +/// Commits what the Info page's property fields hold and the presentation does not: a side-menu +/// action such as Save runs before the field that is being left is drawn again (#422). +fn commit_props(app: &mut SlideApp, bs: &Backstage) { + if bs.page != Page::Info { + return; + } + let Ok(v) = app.session.execute("file.info", &json!({})) else { return }; + let mut p = serde_json::Map::new(); + for ((key, _, _), draft) in PROPERTIES.iter().zip(&bs.props) { + if v["properties"].get(*key).and_then(Value::as_str).unwrap_or("") != *draft { + p.insert((*key).to_string(), json!(draft)); + } + } + if !p.is_empty() { + let _ = app.run("file.properties", Value::Object(p)); + } +} + /// `1234567` → `1.2 MB`. pub fn format_bytes(b: u64) -> String { match b { @@ -890,7 +988,24 @@ pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { _ => ("slides", 0), }; // The list is already filtered for hidden slides, so the PDF takes it as it is. - json!({"layout": layout, "perPage": per, "slides": slides, "includeHidden": true, "frame": bs.print_frame}) + json!({ + "layout": layout, + "perPage": per, + "slides": slides, + "includeHidden": true, + "frame": bs.print_frame, + "paper": bs.print_paper, + "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, + }) +} + +/// The printed page size in points: the slide size for full-page slides, else the chosen paper. +pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { + if bs.print_layout == "slides" { + return (doc.slide_size.width as f32, doc.slide_size.height as f32); + } + let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); + (w as f32, h as f32) } const LAYOUTS: [(&str, &str); 8] = [ @@ -968,6 +1083,28 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } }); ui.add_space(4.0); + // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are + // for notes pages and handouts. + ui.add_enabled_ui(bs.print_layout != "slides", |ui| { + let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); + egui::ComboBox::from_id_salt("backstage_print_paper").width(240.0).selected_text(paper).show_ui(ui, |ui| { + for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { + if ui.selectable_label(bs.print_paper == id, label).clicked() { + bs.print_paper = id; + } + } + }); + ui.add_space(4.0); + let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; + egui::ComboBox::from_id_salt("backstage_print_orientation").width(240.0).selected_text(orient).show_ui(ui, |ui| { + for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + if ui.selectable_label(bs.print_landscape == land, label).clicked() { + bs.print_landscape = land; + } + } + }); + }); + ui.add_space(4.0); if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { bs.preview_page = 0; } @@ -1000,8 +1137,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ let last = pages.len().saturating_sub(1); bs.preview_page = bs.preview_page.min(last); let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; - // Full-page slides print on slide-shaped pages; notes and handouts on portrait Letter. - let (pw, ph) = if bs.print_layout == "slides" { (doc.slide_size.width as f32, doc.slide_size.height as f32) } else { (612.0, 792.0) }; + // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. + let (pw, ph) = print_page_size(bs, doc); let avail_w = ui.available_width().clamp(200.0, 760.0); let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 115d782f..9b3906a2 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -179,6 +179,21 @@ fn every_page_renders() { assert!(h.state_mut().run("app.backstage", json!({"page": "share"})).is_err(), "Share (cloud) isn't offered"); } +/// A starter template from File › New opens its deck and leaves the File tab. +#[test] +fn new_template_opens_a_starter_deck() { + let mut h = harness(new_app()); + h.state_mut().run("app.backstage", json!({"page": "new"})).unwrap(); + h.run_steps(3); + h.get_by_label("Briefing").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.masters[0].theme.name, "Harbor"); + assert_eq!(doc.slides[0].title(), "Weekly briefing"); + assert_eq!(doc.slides.len(), 5); + assert!(h.state().backstage.is_none(), "the starter deck shows"); +} + /// New and Home create presentations, which leaves the File tab. #[test] fn new_creates_a_presentation() { @@ -217,6 +232,15 @@ fn open_lists_recent_files_and_opens_one() { h.run_steps(2); assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name"); h.get_by_label("Quarterly review.pptx").click(); + h.run_steps(1); + // The file is read in the background (#350); its progress window shows meanwhile. + for _ in 0..300 { + if h.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + h.run_steps(1); + } h.run_steps(3); assert_eq!(h.state().session.documents().len(), 2); assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str())); @@ -347,6 +371,44 @@ fn info_shows_and_edits_the_properties() { assert!(h.state().backstage_open()); } +/// Leaving a property field with a click on another field keeps what was typed. +#[test] +fn info_keeps_a_property_draft_when_another_field_is_clicked() { + let mut h = harness(new_app()); + h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(Role::TextInput, "Author").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada"); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Title").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().doc.props.author, "Ada"); +} + +/// Save straight from a property field saves what was typed (the side menu acts before the field +/// is drawn again). +#[test] +fn info_saves_a_property_draft_when_save_is_clicked() { + let dir = temp_dir("info-save"); + let path = dir.join("Draft.pptx").to_string_lossy().to_string(); + let mut s = Session::with_new(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default())); + h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(Role::TextInput, "Author").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada"); + h.run_steps(2); + click_nav(&h, "Save"); + h.run_steps(3); + let doc = h.state().session.active().unwrap(); + assert_eq!(doc.doc.props.author, "Ada"); + assert!(!doc.is_dirty(), "the author went into the saved file"); + let _ = std::fs::remove_dir_all(&dir); +} + /// With nothing open the File tab is the start screen: Home, New and Open, no back arrow, and Esc /// has nowhere to go. #[test] @@ -368,6 +430,34 @@ fn the_start_screen_is_file_home() { assert!(h.state().backstage.is_none()); } +/// #346: a file picked in Open that can't be read used to drop back to the start screen without +/// a word. The reason now shows over the start screen. +#[test] +fn a_file_that_cant_be_opened_says_why() { + let dir = temp_dir("unreadable"); + let path = dir.join("Notes.pptx"); + std::fs::write(&path, b"not a presentation").unwrap(); + let picked = path.to_string_lossy().to_string(); + let services = Services { pick_open: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() }; + let mut h = harness(SlideApp::new(Session::new(), services)); + h.state_mut().run("app.backstage", json!({"page": "open"})).unwrap(); + h.run_steps(2); + h.get_by_label("Browse").click(); + h.run_steps(3); + // The file is read in the background (#350): wait for it. + for _ in 0..500 { + if h.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + h.run_steps(1); + } + h.run_steps(2); + assert!(h.state().session.documents().is_empty()); + assert!(h.query_by_label_contains("not a presentation DeckCraft can read").is_some(), "the error is on screen"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Print previews the pages its settings make and prints through a PDF opened in the system /// viewer. #[test] @@ -435,4 +525,13 @@ fn print_slides_follow_the_settings() { bs.print_layout = "h6"; assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6); assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts"); + assert_eq!(print_params(&bs, &[1])["paper"], "letter"); + assert_eq!(print_page_size(&bs, &doc), (612.0, 792.0)); + bs.print_paper = "a4"; + bs.print_landscape = true; + assert_eq!(print_params(&bs, &[1])["orientation"], "landscape"); + let (w, h) = print_page_size(&bs, &doc); + assert!((w - 841.89).abs() < 0.01 && (h - 595.28).abs() < 0.01); + bs.print_layout = "slides"; + assert_eq!(print_page_size(&bs, &doc), (doc.slide_size.width as f32, doc.slide_size.height as f32), "slides print slide-shaped"); } diff --git a/crates/ui-egui/src/browsetheme_tests.rs b/crates/ui-egui/src/browsetheme_tests.rs new file mode 100644 index 00000000..cb65ec59 --- /dev/null +++ b/crates/ui-egui/src/browsetheme_tests.rs @@ -0,0 +1,49 @@ +//! Design › Browse for Themes…: picking a .pptx applies its theme. + +use std::cell::RefCell; +use std::rc::Rc; + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn browse_button_applies_the_picked_files_theme() { + let dir = std::env::temp_dir().join(format!("deckcraft-thmx-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut brand = Session::with_new(); + brand.execute("design.fonts", &json!({"major": "Lato", "minor": "Lato"})).unwrap(); + let path = dir.join("Brand.pptx"); + brand.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let asked: Rc>> = Rc::default(); + let log = asked.clone(); + let p2 = path.to_string_lossy().to_string(); + let services = Services { + pick_open: Some(Box::new(move |purpose: &str| { + log.borrow_mut().push(purpose.to_string()); + Some(p2.clone()) + })), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "Hello"})).unwrap(); + let mut app = SlideApp::new(s, services); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Browse for Themes…").click(); + h.run_steps(3); + assert_eq!(asked.borrow().as_slice(), ["theme"]); + assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.fonts.major.latin, "Lato"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..bc4e080e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,13 +35,44 @@ fn mods(ui: &Ui) -> Mods { ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) } +/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; +/// other platforms leave the delta at zero. +/// +/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom +/// and two-finger scrolling are left as they are. +pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + let delta = ctx.input(|i| i.rotation_delta()); + let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; + let turning = delta.abs() > 1e-4; + let blocked = app.show.is_some() + || app.backstage_open() + || app.dialog.as_ref().is_some_and(|d| d.modal()) + || app.palette.is_some() + || app.session.tool.dragging(); + let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); + let rotatable = app.session.active().is_some_and(|d| { + d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) + }); + if !blocked && turning && rotatable && (app.twisting || over) { + let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); + app.twisting = true; + } else if app.twisting { + let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); + app.twisting = false; + } +} + /// The slide's rect on screen for the current zoom. pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let Some(d) = app.session.active() else { return (avail, 1.0) }; - let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32); + let page = deckcraft_engine::page_size(d); + // Loaded sizes are repaired; a zero or non-finite side would still make the fit infinite. + let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 }; + let (sw, sh) = (side(page.width), side(page.height)); let margin = 28.0; let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05); - let scale = app.ui.zoom.unwrap_or(fit); + // A zoom from outside the editor's own controls (settings, the control protocol) is kept in range. + let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); let size = vec2(sw * scale, sh * scale); let mut r = Rect::from_center_size(avail.center(), size); if size.x > avail.width() { @@ -53,6 +84,17 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -66,51 +108,75 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } return; } - let (content_rect, scale) = layout(app, avail); // Scroll when zoomed beyond the window. let scroll_id = ui.id().with("canvas_scroll"); + let last_id = scroll_id.with("last"); let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default()); - let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id)); let resp = ui.allocate_rect(avail, Sense::click_and_drag()); - let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); - if resp.hovered() { - if (zoom_delta - 1.0).abs() > 1e-3 { - let cur = app.ui.zoom.unwrap_or(scale); - app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); - let _ = pinch_pos; - } else if overflow != egui::Vec2::ZERO { + let (wheel, zoom_delta, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); + let zooming = resp.hovered() && (zoom_delta - 1.0).abs() > 1e-3; + if zooming { + let cur = app.ui.zoom.unwrap_or_else(|| layout(app, avail).1); + app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); + } + let (content_rect, scale) = layout(app, avail); + // A new zoom keeps the slide point under the pointer (wheel, pinch) or under the middle of the + // canvas (Zoom In/Out, the slider) where it was, instead of growing from the corner (#106). + if let Some((last_rect, last_scale)) = last + && (scale - last_scale).abs() > 1e-6 + { + let anchor = if zooming { hover.unwrap_or(avail.center()) } else { avail.center() }; + let at = (anchor - last_rect.min) / last_scale; + offset = content_rect.min + at * scale - anchor; + } + let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + if resp.hovered() && !zooming { + if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - // Wheel over a fitted slide pages through slides (like PowerPoint). - let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; + } else if !app.anim_preview_active() + && app.session.tool.marquee.is_none() + && !app.session.tool.dragging() + && let Some(next) = page_flick(wheel) + { + // A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is + // the branch above and is left alone. let gate = ui.id().with("wheel_gate"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0)); + // No previous flick yet: the first one must turn the page, not wait out the gap. + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); let now = ui.input(|i| i.time); if now - last > 0.35 { ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(id, json!({})); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); } } } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - ui.data_mut(|d| d.insert_temp(scroll_id, offset)); - let slide_rect = content_rect.translate(-offset); + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The + // zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift. + let ppp = ui.ctx().pixels_per_point(); + let exact_rect = content_rect.translate(-offset); + let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp); + ui.data_mut(|d| { + d.insert_temp(scroll_id, offset); + d.insert_temp(last_id, (exact_rect, scale)); + }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; let xf = Xf { origin: slide_rect.min, scale }; let painter = ui.painter_at(avail); // Shadow + slide. painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); - let ppp = ui.ctx().pixels_per_point(); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); let gifs = match app.session.active() { - Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused), + Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), _ => vec![], }; crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + let anim = app.anim_preview_draw(); let st = app.session.active(); // One texture pixel in screen points, and the canvas in pixels from the slide's corner. let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32); @@ -119,10 +185,17 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { Some((Target::Slides, d)) => { let idx = d.selection.slide; let doc = d.doc.clone(); - app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×) + let frame = anim.as_ref().map(|(tl, t)| (tl, *t)); + // A placeholder's prompt makes way for the caret once it is being typed into. + let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape); + let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty())); + app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt, frame) } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), + Some((t @ (Target::Handout | Target::NotesMaster), d)) => { + deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible)) + } None => None, }; if let Some((tex, region)) = tex { @@ -135,39 +208,72 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } app.perf.render_ms = app.textures.last_render_ms; media_frames(app, &painter, xf); - if app.ui.gridlines { - let step = 72.0 / 2.0 * scale; - let mut x = slide_rect.min.x + step; - while x < slide_rect.max.x { - painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - x += step; - } - let mut y = slide_rect.min.y + step; - while y < slide_rect.max.y { - painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - y += step; + // Notes Master view: the current slide inside the slide-image placeholder, as in PowerPoint. + let notes_preview = app.session.active().filter(|d| d.selection.target == Target::NotesMaster).and_then(|d| { + let frame = d.doc.notes_master.as_ref()?.shapes.iter().find(|s| s.ph_type() == Some(deckcraft_model::PhType::SlideImage))?.xfrm?; + Some((d.doc.clone(), d.selection.slide.min(d.doc.slides.len().saturating_sub(1)), frame)) + }); + if let Some((doc, idx, f)) = notes_preview + && f.w > 1.0 + && f.h > 1.0 + { + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let (w, h) = if f.w / f.h > aspect { (f.h * aspect, f.h) } else { (f.w, f.w / aspect) }; + let (x, y) = (f.x + (f.w - w) / 2.0, f.y + (f.h - h) / 2.0); + let r = Rect::from_min_size( + pos2(slide_rect.min.x + x as f32 * scale, slide_rect.min.y + y as f32 * scale), + vec2(w as f32 * scale, h as f32 * scale), + ); + let mut budget = 2; + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } + if let Some(d) = app.session.active().filter(|d| d.selection.target == Target::Handout) { + // Where the slides go on a printed handout, as in PowerPoint's preview. + let (n, s) = (d.doc.notes_size, d.doc.slide_size); + let aspect = s.width / s.height.max(1.0); + for b in deckcraft_engine::pdf::handout_boxes(app.ui.handout_per_page, (n.width, n.height), aspect) { + let r = Rect::from_min_max( + pos2(slide_rect.min.x + b.x0 as f32 * scale, slide_rect.min.y + b.y0 as f32 * scale), + pos2(slide_rect.min.x + b.x1 as f32 * scale, slide_rect.min.y + b.y1 as f32 * scale), + ); + let dash = egui::Shape::dashed_line( + &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], + Stroke::new(1.0, Color32::from_gray(140)), + 4.0, + 3.0, + ); + painter.extend(dash); + } + } + if app.ui.gridlines + && let Some(step) = grid_line_step(app.session.prefs.grid_spacing, scale) + { + draw_grid(&painter, slide_rect, step); + } + app.session.tool.guides_visible = app.ui.guides; if app.ui.guides { - let c = slide_rect.center(); - painter.line_segment( - [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); - painter.line_segment( - [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); + draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); + ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); - overlays(app, ui, &painter, xf, &t); - ime_output(app, ui, xf); - media_bar(app, ui, xf); - gif_buttons(app, ui, xf, &gifs); - context_menu(app, &resp); + if !app.anim_preview_active() { + overlays(app, ui, &painter, xf, &t); + ime_output(app, ui, xf); + media_bar(app, ui, xf); + gif_buttons(app, ui, xf, &gifs); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64)); + app.squiggles.menu_word = word; + } + context_menu(app, &resp); + } } /// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's @@ -276,6 +382,7 @@ fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) { /// selection or hover). fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { let Some(d) = app.session.active() else { return }; + let uid = d.uid; if d.selection.target != Target::Slides || d.selection.text.is_some() || app.session.tool.dragging() { return; } @@ -294,11 +401,11 @@ fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { let bar = Rect::from_min_size(pos2(r.center().x - w / 2.0, r.max.y + 6.0), vec2(w, 26.0)); let status = app.media.status(id); match crate::media::control_bar(ui, bar, status.as_ref(), &clip) { - Some(crate::media::BarAction::Toggle) => app.media.toggle(&doc, id, &clip, crate::media::Owner::Editor), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(&doc, id, &clip, t, crate::media::Owner::Editor), + Some(crate::media::BarAction::Toggle) => app.media.toggle(uid, &doc, id, &clip, crate::media::Owner::Editor), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &doc, id, &clip, t, crate::media::Owner::Editor), Some(crate::media::BarAction::Nudge(dt)) => { let pos = status.map(|s| s.position).unwrap_or(0.0); - app.media.seek(&doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); + app.media.seek(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); } None => {} } @@ -313,14 +420,18 @@ fn master_tex( visible: deckcraft_render::Region, ) -> Option<(egui::TextureHandle, deckcraft_render::Region)> { let scale = px.0 as f64 / doc.slide_size.width.max(1.0); - let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible); + let region = crate::textures::fit_texture( + deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible), + visible, + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_layout( doc, master, layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { @@ -366,17 +477,268 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); + let id = ui.id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); + } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } +} + +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if crate::eyedropper::canvas(app, ui, resp, xf) { + return; + } + if app.anim_preview_active() { + // A click on the slide ends the preview, then selects as usual. + if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { + app.stop_anim_preview(); + } else { + return; + } + } let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; let primary = ui.input(|i| i.pointer.primary_pressed()); if resp.hovered() && !app.session.tool.dragging() { let _ = app.session.pointer(ev(PointerKind::Move)); } - let r = if resp.triple_clicked() { + let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); + let r = if secondary && !app.session.tool.dragging() { + // Select what's under the pointer before the context menu opens (on release). + app.session.pointer(ev(PointerKind::ContextClick)) + } else if resp.triple_clicked() { app.session.pointer(ev(PointerKind::TripleClick)) } else if resp.double_clicked() { app.session.pointer(ev(PointerKind::DoubleClick)) @@ -404,7 +766,9 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { egui::CursorIcon::Text @@ -412,8 +776,12 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { egui::CursorIcon::Move } } - (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, - _ => egui::CursorIcon::Default, + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -430,6 +798,14 @@ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { } fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } let Some(st) = app.session.active() else { return }; let sel_stroke = Stroke::new(1.0, t.selection); let editing = st.selection.text.clone(); @@ -452,6 +828,35 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To continue; } let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); + if app.session.tool.editing_points(st) == Some(sh.id) { + // Edit Points: the outline's vertices are the handles. + // No selection box: the outline and its vertices are what's edited. + if let Some((path, index)) = app.session.tool.edit_vertex + && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for h in [hin, hout].into_iter().flatten() { + painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); + painter.rect( + egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), + 0.0, + Color32::WHITE, + Stroke::new(1.0, t.selection), + egui::StrokeKind::Middle, + ); + } + } + for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { + let c = xf.to_screen(q); + painter.rect( + egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), + 0.0, + Color32::BLACK, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + continue; + } if editing_this { // Dashed box while editing text. for i in 0..4 { @@ -484,23 +889,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let q = xf.to_screen(a * hd.pos); painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } } } - // Caret / text selection. - if let Some(ts) = &editing - && !ts.notes - && ts.cell.is_none() - && let Some(sh) = st.shape(ts.shape) - && let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts) - { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - // Where the text is drawn: flips don't mirror it, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - let (s0, s1) = ts.ordered(); - for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) { - let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); - painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); + // Caret / text selection, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); } // An input method's composition: underlined in place, thicker under the part being converted. for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { @@ -511,22 +909,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); // An empty placeholder shows the caret where typing begins too. if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); + // In the colour of the text being typed: a black caret is lost on a dark slide. + let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); + painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); } } } - // Table cell editing: outline the cell. + // Table cell editing: outline the cell (merged cells as drawn). if let Some(ts) = &editing && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let deckcraft_model::ShapeKind::Table(tb) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let heights = deckcraft_engine::tools::table_rows(st, tb); - let y0: f64 = heights.iter().take(r).sum(); - let x0: f64 = tb.cols.iter().take(c).sum(); - let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0)); - let a = x.affine(); - let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q)); + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); } // Smart guides, marquee, previews. @@ -581,13 +977,60 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if app.session.tool.ink_preview.len() > 1 && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind { - let pts: Vec = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + let pts = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); let c = theme::to_color32(*color); let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + if legacy || mode == "highlighter" { + // One line, so see-through highlighter ink doesn't darken where segments meet. + let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + } else { + for pair in pts.windows(2) { + let [q0, q1] = pair else { continue }; + let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(q0.0, q0.1)); + let p1 = xf.to_screen(Point::new(q1.0, q1.1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } + } } } +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + +/// The text selection (quads) of the text being edited, in slide space: in a shape or a table +/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. +pub(crate) struct TextMarks { + pub selection: Vec<[Point; 4]>, +} + +pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { + let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; + let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; + let (s0, s1) = ts.ordered(); + let selection = l + .selection_rects(pos(s0), pos(s1)) + .into_iter() + .map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q)) + .collect(); + Some(TextMarks { selection }) +} + fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { p.circle_filled(c, 6.0, t.handle_fill); p.add(egui::Shape::Path(egui::epaint::PathShape { @@ -608,6 +1051,16 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) ToolKind::Shape { preset } => preset.clone(), _ => "rect".into(), }; + if preset == deckcraft_engine::tools::DRAW_TABLE { + // A border inside a table (flat), or the outline of a new table. + let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if x.w < 0.5 || x.h < 0.5 { + p.line_segment([a, b], Stroke::new(1.5, t.accent)); + } else { + p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + return; + } if deckcraft_geom::preset::is_line_like(&preset) { let a = x.affine(); p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent)); @@ -621,7 +1074,106 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) } fn context_menu(app: &mut SlideApp, resp: &egui::Response) { + // Right-clicking a vertex in Edit Points picks it for the point menu. + if resp.secondary_clicked() + && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() + { + app.session.tool.edit_vertex = Some((path, index)); + } resp.context_menu(|ui| { + let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); + if let Some(sh) = editing { + if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); + ui.close(); + } + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } + return; + } + // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. + if let Some(w) = app.squiggles.menu_word.clone() { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app + .session + .active() + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Notes { .. } => {} + } + } + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); + } let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { @@ -630,11 +1182,48 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } ui.separator(); - if has_sel { + // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. + let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); + if editing { + if ui.button("Paragraph…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + ui.close(); + } + for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else if has_sel { if ui.button("Edit Text").clicked() { let _ = app.run("text.edit", json!({})); ui.close(); } + if crate::ribbon::enabled(app, "shape.editPoints") + && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) + && ui.button("Edit Points").clicked() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); @@ -652,6 +1241,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("shape.lock", json!({"locked": !locked})); ui.close(); } + let picture = app + .session + .active() + .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Picture…").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); @@ -664,6 +1261,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("shape.setDefault", json!({})); ui.close(); } + if ui.button("Size and Position…").clicked() { + let _ = app.run("shape.sizePosition", json!({})); + ui.close(); + } if ui.button("Format Shape…").clicked() { let _ = app.run("view.pane", json!({"pane": "format"})); ui.close(); @@ -682,6 +1283,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); if ui.button("Reset Slide").clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); @@ -697,3 +1311,241 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_engine::Session; + use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; + use egui_kittest::Harness; + + use super::*; + use crate::{Services, SlideApp}; + + fn harness() -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h + } + + /// The slide point (in slide points) under screen position `p`. + fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 { + let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) + } + + fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { + let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }; + h.event(wheel); + // egui spreads a wheel notch over several frames. + h.run_steps(30); + } + + /// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel + /// grid (#184). + fn assert_near(a: Pos2, b: Pos2, what: &str) { + assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); + } + + /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. + #[test] + fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() { + let mut h = harness(); + let fit = h.state().canvas_scale; + let r = h.state().slide_rect.unwrap(); + let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let under = slide_point(&h, p); + + h.event(Event::Zoom(2.0)); + h.run_steps(2); + assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3); + assert_near(under, slide_point(&h, p), "pinch in"); + + ctrl_wheel(&mut h, 1.0); + assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in"); + assert_near(under, slide_point(&h, p), "wheel in"); + + ctrl_wheel(&mut h, -1.0); + ctrl_wheel(&mut h, -1.0); + assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out"); + assert_near(under, slide_point(&h, p), "wheel out"); + + h.event(Event::Zoom(0.8)); + h.run_steps(2); + assert_near(under, slide_point(&h, p), "pinch out"); + + // Zoomed out below the window size, the slide is centred again. + h.event(Event::Zoom(0.25)); + h.run_steps(2); + assert!(h.state().canvas_scale < fit); + let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); + assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}"); + } + + /// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is. + #[test] + fn zoom_commands_keep_the_middle_of_the_view() { + let mut h = harness(); + let c = h.state().canvas_rect.unwrap().center(); + let mid = slide_point(&h, c); + for _ in 0..4 { + h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom in"); + } + h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom out"); + } + + fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { + let mut s = Session::with_new(); + let data = json!([["Hello world", "b"], ["c", ""]]); + let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("edit.deselect", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + (harness, id) + } + + /// Screen position of slide point (x, y). + fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { + Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) + } + + /// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). + fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + let t = harness.ctx.input(|i| i.time) + 0.05; + harness.input_mut().time = Some(t); + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. + #[test] + fn double_click_in_a_table_cell_shows_the_caret_there() { + let (mut harness, id) = app_with_table(); + // The empty cell (1, 1) spans x 300..500, y 140..180. + let p = screen(harness.state(), 400.0, 160.0); + harness.hover_at(p); + harness.step(); + click(&mut harness, p); + click(&mut harness, p); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().expect("editing the cell"); + assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); + assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); + let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); + for q in [top, bottom] { + assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); + } + // A click in another cell moves the caret there. + let p = screen(harness.state(), 110.0, 120.0); + click(&mut harness, p); + harness.run_steps(1); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().unwrap(); + assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); + let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); + assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); + } + + /// Table borders show a resize cursor and drag to resize columns. + #[test] + fn table_borders_resize_with_the_pointer() { + let (mut harness, id) = app_with_table(); + let p = screen(harness.state(), 300.0, 120.0); + harness.hover_at(p); + harness.step(); + harness.step(); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); + let q = screen(harness.state(), 340.0, 120.0); + harness.drag_at(p); + harness.step(); + for k in 1..=4 { + harness.hover_at(p + (q - p) * k as f32 / 4.0); + harness.step(); + } + harness.drop_at(q); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; + assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); + } + + #[test] + fn grid_step_follows_spacing_and_rejects_unusable_values() { + let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(super::grid_line_step(6.0, 1.0).is_some()); + assert!(super::grid_line_step(0.0, 1.0).is_none()); + assert!(super::grid_line_step(f64::NAN, 1.0).is_none()); + assert!(super::grid_line_step(1_000.0, 1.0).is_none()); + assert!(super::grid_line_step(6.0, 0.0).is_none()); + assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); + } +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs new file mode 100644 index 00000000..9108b4de --- /dev/null +++ b/crates/ui-egui/src/compare_tests.rs @@ -0,0 +1,61 @@ +//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide. +fn harness() -> (Harness<'static, SlideApp>, usize) { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": n - 2})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, n) +} + +#[test] +fn pane_opens_lists_changes_and_accept_all_applies_them() { + let (mut h, n) = harness(); + assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane"); + assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some()); + assert!(h.query_by_label("Slide 2 deleted").is_some()); + assert!(h.query_by_label_contains("Slide inserted after").is_some()); + h.get_by_label_contains("Accept All").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added"); + assert!(h.query_by_label("No differences left.").is_some()); + h.get_by_label("End Review").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().compare.is_none()); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let (mut h, _) = harness(); + // Review tab. + h.state_mut().ui.tab = "Review".into(); + h.run_steps(4); + h.render().unwrap().save("/tmp/compare.png").unwrap(); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..8f474485 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -3,8 +3,8 @@ //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI) //! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in -//! slide points (same path as the mouse) +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, pressure?}], mods}`: drive the active tool in +//! slide points (same path as the mouse; `pressure` is `0..=1` when a pen reported one) //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine //! - `ui.tool.select {tool, preset?}` //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer @@ -75,7 +75,15 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value { "dialog": app.dialog.as_ref().map(|d| d.id.clone()), "home": app.backstage_open(), "backstage": app.backstage.as_ref().map(|b| b.page.id()), - "showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})), + "showing": app.show.as_ref().map(|s| { + let (n, aspect) = app.session.active().map(|d| { + let h = d.doc.slide_size.height.max(1.0) as f32; + (d.doc.slides.len(), d.doc.slide_size.width as f32 / h) + }).unwrap_or((0, 16.0 / 9.0)); + let now = ctx.input(|i| i.time); + s.showing_value(n, aspect, ctx.content_rect(), now) + }), + "animPreview": app.anim_preview_report().map(|(slide, time, duration)| json!({"slide": slide, "time": time, "duration": duration})), "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::>(), "status": app.status.as_ref().map(|s| s.0.clone()), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, @@ -125,7 +133,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> return err("pointer events need x and y"); }; let mods = e.get("mods").map(mods_from).unwrap_or(base); - match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) { + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64, pressure }) { Ok(v) => last = v, Err(e) => return err(e), } @@ -134,6 +143,10 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.key" => { let Some(k) = s("key") else { return err("missing `key`") }; + if app.anim_preview_active() && app.show.is_none() && matches!(k.to_ascii_lowercase().as_str(), "escape" | "esc") { + app.stop_anim_preview(); + return ok(json!({"preview": false})); + } if let Some(show) = app.show.as_ref() && !show.ended && let Some(key) = key_from(k) @@ -229,7 +242,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }; } if let Some(z) = p.get("zoom") { - app.ui.zoom = z.as_f64().map(|v| v as f32); + app.ui.zoom = z + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && *v > 0.0) + .map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); } if let Some(d) = p.get("dark").and_then(Value::as_bool) { let _ = app.run("view.dark", json!({"on": d})); @@ -293,8 +310,15 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> ok(Value::Null) } "show.start" => { - let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; - app.start_show(from, p.get("reading").and_then(Value::as_bool).unwrap_or(false)); + let reading = p.get("reading").and_then(Value::as_bool).unwrap_or(false); + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + app.start_custom_show(name, reading); + } else if p.get("from").is_some() { + let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; + app.start_show(from, reading); + } else { + app.start_show(app.show_start_index(), reading); + } ok(Value::Null) } "show.end" => { @@ -342,6 +366,7 @@ fn deckcraft_mcp_kind(k: &str) -> Option { "move" => Move, "doubleclick" | "dblclick" => DoubleClick, "tripleclick" => TripleClick, + "contextclick" | "rightclick" => ContextClick, _ => return None, }) } diff --git a/crates/ui-egui/src/datetime_tests.rs b/crates/ui-egui/src/datetime_tests.rs new file mode 100644 index 00000000..c955aa2e --- /dev/null +++ b/crates/ui-egui/src/datetime_tests.rs @@ -0,0 +1,78 @@ +//! Insert › Date and Time, driven like a person: the ribbon opens the dialog, a format is +//! chosen, OK inserts it in one undo, Cancel leaves the slide alone. + +use deckcraft_engine::Session; +use deckcraft_model::text::RunKind; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Insert".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn date_run(h: &Harness<'_, SlideApp>) -> Option { + h.state() + .session + .active() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() +} + +#[test] +fn insert_opens_the_dialog_and_ok_inserts_one_field() { + let mut h = harness(); + h.get_by_label("Date & Time").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("dateTime")); + let _ = h.get_by_label("Update automatically"); + let _ = h.get_by_label("English (United States)"); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let sample = deckcraft_text::datetime::field_text("datetime2", Some("en-US"), now).unwrap(); + h.get_by_label(&sample).click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let run = date_run(&h).unwrap(); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime2"), "{run:?}"); + assert_eq!(run.text, sample); + let undo = &h.state().session.active().unwrap().history.undo; + assert_eq!(undo.len(), 1); + assert_eq!(undo[0].label, "Date & Time"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + h.run_steps(1); + assert!(date_run(&h).is_none()); +} + +#[test] +fn cancel_closes_the_dialog_without_a_date() { + let mut h = harness(); + h.get_by_label("Date & Time").click(); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(date_run(&h).is_none()); + assert!(h.state().session.active().unwrap().history.undo.is_empty()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..18d112c8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,6 +1,7 @@ -//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). +use deckcraft_color::SchemeSlot; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -32,6 +33,7 @@ fn title(id: &str) -> &'static str { match id { "table" => "Insert Table", "slideSize" => "Slide Size", + "sizePosition" => "Size and Position", "headerFooter" => "Header and Footer", "hyperlink" => "Insert Hyperlink", "setupShow" => "Set Up Slide Show", @@ -43,19 +45,32 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", + "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", + "autocorrect" => "AutoCorrect", + "gridOptions" => "Grid Options", "export" => "Export", "symbol" => "Symbol", + "photoAlbum" => "Photo Album", + "replaceFonts" => "Replace Font", "equation" => "Equation", + "dateTime" => "Date and Time", "spelling" => "Spelling", + "pasteSpecial" => "Paste Special", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", "chartData" => "Chart Data", "trim" => "Trim Media", "smartart" => "Choose a SmartArt Graphic", + "bullets" => "Bullets and Numbering", + "action" => "Action Settings", "quit" => "DeckCraft", + "themeColors" => "Create New Theme Colors", + "themeFonts" => "Create New Theme Fonts", _ => "DeckCraft", } } @@ -64,18 +79,43 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false; - egui::Window::new(title(&d.id)) + let mut window = egui::Window::new(title(&d.id)) .id(egui::Id::new(("dialog", d.id.clone()))) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) - .open(&mut open) - .show(ctx, |ui| { - ui.set_min_width(340.0); - close = body(app, ui, &mut d); + .open(&mut open); + // Two columns of colour rows plus Save must fit the first frame (Theme Colors), as must Save + // in Theme Fonts. A later resize clips the buttons (the window size sticks) and clicks miss them. + if d.id == "themeColors" { + window = window.default_size(vec2(760.0, 420.0)).min_width(720.0).min_height(420.0); + } else if d.id == "themeFonts" { + window = window.default_size(vec2(460.0, 320.0)).min_width(420.0).min_height(300.0); + } + window.show(ctx, |ui| { + ui.set_min_width(match d.id.as_str() { + "themeColors" => 700.0, + "themeFonts" => 400.0, + _ => 340.0, }); - if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if d.id == "themeColors" { + ui.set_min_height(360.0); + } else if d.id == "themeFonts" { + ui.set_min_height(280.0); + } + close = body(app, ui, &mut d); + }); + let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if open && !close && !escape { app.dialog = Some(d); + } else if app.dialog.is_none() + && let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned() + { + // Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there. + let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph"); + let mut back = Dialog::new(id); + back.params = ret; + app.dialog = Some(back); } } @@ -106,6 +146,18 @@ fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { d.fields.insert(key.into(), v); } +/// "Mark as decorative" starts checked when every selected shape is already decorative. +fn alt_text_starts_decorative(decorative: impl IntoIterator) -> bool { + let mut any = false; + for on in decorative { + if !on { + return false; + } + any = true; + } + any +} + fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool { let mut v = d.get(key, if default { "1" } else { "0" }) == "1"; ui.checkbox(&mut v, label); @@ -117,6 +169,830 @@ fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } +fn album_items(d: &Dialog) -> Option> { + let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); + serde_json::from_str::(raw).ok().and_then(|v| v.as_array().cloned()) +} + +fn store_album_items(d: &mut Dialog, items: &[Value]) { + let raw = serde_json::to_string(items).unwrap_or_else(|_| "[]".into()); + d.fields.insert("items".into(), raw); +} + +fn album_item_label(v: &Value) -> String { + if let Some(text) = v.get("text").and_then(Value::as_str) { + let text = text.trim(); + if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") } + } else { + v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string() + } +} + +fn pick_album_picture(app: &mut SlideApp) -> Option { + let path = if let Some(pick) = app.services.pick_open.as_mut() { + pick("picture") + } else { + app.set_status("Pass pictures to insert.photoAlbum"); + None + }; + let path = path?; + let loaded = if let Some(read) = app.services.read.as_ref() { + match read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(e); + return None; + } + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(format!("{path}: {e}")); + return None; + } + } + } + #[cfg(target_arch = "wasm32")] + { + app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum"); + return None; + } + }; + if loaded.is_empty() { + app.set_status("That file is empty"); + return None; + } + if loaded.len() > 32 * 1024 * 1024 { + app.set_status("That picture is larger than 32 MB"); + return None; + } + let name = std::path::Path::new(&path).file_name().and_then(|n| n.to_str()).filter(|s| !s.is_empty()).unwrap_or("picture").to_string(); + Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) +} + +fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, default); + let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); + let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { + for (k, l) in choices { + if ui.selectable_label(cur == *k, *l).clicked() { + d.fields.insert(key.into(), (*k).to_string()); + } + } + }); + opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); +} + +const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]; + +fn current_theme(app: &SlideApp) -> String { + app.session + .active() + .and_then(|d| d.doc.masters.first()) + .map(|m| m.theme.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "DeckCraft".into()) +} + +fn album_json_id(v: &Value) -> Option { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) +} + +/// Copy a command's dialog params into fields once. A fresh album has no `items`. +fn seed_photo_album(app: &SlideApp, d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + d.fields.insert("seeded".into(), "1".into()); + let p = d.params.clone(); + if let Some(items) = p.get("items").and_then(Value::as_array) { + store_album_items(d, items); + d.fields.insert("editing".into(), "1".into()); + } + for key in ["layout", "frame", "theme"] { + if let Some(v) = p.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) { + d.fields.insert(key.into(), v.to_string()); + } + } + for key in ["captions", "grayscale"] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); + } + } + if let Some(id) = p.get("album").and_then(album_json_id).filter(|n| *n != 0) { + d.fields.insert("album".into(), id.to_string()); + } + let theme_was = d.fields.get("theme").cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| current_theme(app)); + if !d.fields.contains_key("theme") { + d.fields.insert("theme".into(), theme_was.clone()); + } + d.fields.entry("themeWas".into()).or_insert(theme_was); +} + +fn selected_album_picture(d: &Dialog, items: &[Value]) -> bool { + let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); + items.get(i).is_some_and(|item| item.get("data").is_some() || item.get("path").is_some()) +} + +fn with_selected_picture(d: &mut Dialog, f: impl FnOnce(&mut serde_json::Map)) { + let Some(mut list) = album_items(d) else { return }; + let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); + let Some(item) = list.get_mut(i) else { return }; + if item.get("data").is_none() && item.get("path").is_none() { + return; + } + let Some(obj) = item.as_object_mut() else { return }; + f(obj); + store_album_items(d, &list); +} + +/// Photo Album. Returns true when the dialog should close. +fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_photo_album(app, d); + ui.set_min_width(440.0); + egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| { + ui.set_min_width(420.0); + ui.label("Pictures in album"); + let items = album_items(d).unwrap_or_default(); + let mut sel = d.get("sel", "0").parse::().unwrap_or(0); + // Fixed height so adding pictures does not push Create past the window. + egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| { + if items.is_empty() { + ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); + } + for (i, item) in items.iter().enumerate() { + if ui.selectable_label(sel == i, album_item_label(item)).clicked() { + sel = i; + } + } + }); + d.fields.insert("sel".into(), sel.to_string()); + ui.horizontal(|ui| { + if ui.button("Add Pictures…").clicked() + && let Some(item) = pick_album_picture(app) + { + match album_items(d) { + Some(mut list) if list.len() < 48 => { + list.push(item); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"), + None => app.set_status("The picture list is invalid"), + } + } + if ui.button("New Text Box").clicked() { + let text = d.get("boxText", "Text Box"); + let text = text.trim(); + if text.is_empty() { + app.set_status("Type the text for the new text box"); + } else if text.chars().count() > 400 { + app.set_status("Text is longer than 400 characters"); + } else if let Some(mut list) = album_items(d) { + if list.len() >= 48 { + app.set_status("A photo album holds at most 48 pictures or text boxes"); + } else { + list.push(json!({"text": text})); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + } + } + if ui.button("Remove").clicked() + && let Some(mut list) = album_items(d) + && let Some(i) = d.get("sel", "0").parse::().ok() + && i < list.len() + { + list.remove(i); + store_album_items(d, &list); + let next = i.min(list.len().saturating_sub(1)); + d.fields.insert("sel".into(), next.to_string()); + } + }); + field(ui, d, "Text box:", "boxText", "Text Box"); + let picture = selected_album_picture(d, &items); + ui.label(album_picture_status(&items, sel)); + ui.horizontal_wrapped(|ui| { + ui.add_enabled_ui(picture, |ui| { + if ui.button("Rotate Left").clicked() { + with_selected_picture(d, |obj| { + let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0; + obj.insert("rot".into(), json!(next)); + }); + } + if ui.button("Rotate Right").clicked() { + with_selected_picture(d, |obj| { + let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0; + obj.insert("rot".into(), json!(next)); + }); + } + if ui.button("More Brightness").clicked() { + nudge_picture(d, "brightness", 0.1); + } + if ui.button("Less Brightness").clicked() { + nudge_picture(d, "brightness", -0.1); + } + if ui.button("More Contrast").clicked() { + nudge_picture(d, "contrast", 0.1); + } + if ui.button("Less Contrast").clicked() { + nudge_picture(d, "contrast", -0.1); + } + }); + }); + ui.add_space(4.0); + ui.label("Picture layout"); + album_choice( + ui, + d, + "album_layout", + "layout", + "1", + &[ + ("fit", "Fit to slide"), + ("1", "1 picture"), + ("2", "2 pictures"), + ("4", "4 pictures"), + ("1title", "1 picture with title"), + ("2title", "2 pictures with title"), + ("4title", "4 pictures with title"), + ], + ); + ui.label("Frame shape"); + album_choice( + ui, + d, + "album_frame", + "frame", + "rect", + &[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")], + ); + check(ui, d, "Captions under pictures", "captions", false); + check(ui, d, "ALL pictures black and white", "grayscale", false); + ui.label("Album theme"); + album_choice(ui, d, "album_theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); + }); + let captions = d.fields.get("captions").is_some_and(|v| v == "1"); + let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1"); + let editing = d.fields.get("editing").is_some_and(|v| v == "1"); + let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" }); + if ok { + let Some(list) = album_items(d) else { + app.set_status("The picture list is invalid"); + return false; + }; + if list.is_empty() { + app.set_status("Add a picture or a text box first"); + return false; + } + let layout = d.get("layout", "1"); + let frame = d.get("frame", "rect"); + let theme = d.get("theme", "DeckCraft"); + let theme_was = d.fields.get("themeWas").cloned().unwrap_or_default(); + let mut payload = json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale}); + if theme != theme_was + && let Some(obj) = payload.as_object_mut() + { + obj.insert("theme".into(), json!(theme)); + } + if editing { + if let Some(album) = d.fields.get("album").filter(|s| !s.is_empty() && s.as_str() != "0") + && let Ok(id) = album.parse::() + && let Some(obj) = payload.as_object_mut() + { + obj.insert("album".into(), json!(id)); + } + return app.run("insert.photoAlbumEdit", payload).is_ok(); + } + return app.run("insert.photoAlbum", payload).is_ok(); + } + cancel +} + +fn album_picture_status(items: &[Value], sel: usize) -> String { + let Some(item) = items.get(sel) else { + return "Select a picture to rotate it or change brightness and contrast".into(); + }; + if item.get("data").is_none() && item.get("path").is_none() { + return "Text boxes have no picture options".into(); + } + let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0); + let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0); + format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}") +} + +fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) { + with_selected_picture(d, |obj| { + let cur = obj.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let next = (((cur + delta) * 10.0).round() / 10.0).clamp(-1.0, 1.0); + obj.insert(key.into(), json!(next)); + }); +} + +fn seed_theme_colors(d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); + d.fields.insert("schemeName".into(), name.to_string()); + let obj = d.params.get("colors").and_then(Value::as_object); + for slot in SchemeSlot::THEME { + let hex = obj.and_then(|o| o.get(slot.xml_name())).and_then(Value::as_str).unwrap_or("000000"); + d.fields.insert(slot.xml_name().into(), hex.to_string()); + } + d.fields.insert("seeded".into(), "1".into()); +} + +fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { + let key = slot.xml_name(); + let mut v = d.get(key, "000000"); + ui.horizontal(|ui| { + ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label())); + let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200)); + let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key))); + let shown = v.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, format!("{} hex", slot.label())); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert(key.into(), v); +} + +fn theme_color_payload(d: &mut Dialog) -> Result { + let name = d.get("schemeName", "").trim().to_string(); + if name.is_empty() { + return Err("Enter a scheme name.".into()); + } + let mut colors = serde_json::Map::new(); + for slot in SchemeSlot::THEME { + let raw = d.get(slot.xml_name(), ""); + let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else { + return Err(format!("{} needs a colour like #RRGGBB.", slot.label())); + }; + colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex()))); + } + Ok(json!({"name": name, "colors": colors})) +} + +/// Create New Theme Colors. Returns true when the dialog should close. +fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_theme_colors(d); + // The window keeps the height of its contents and will not grow after the first frame, so the + // rows stay short enough that Save is inside that rect. + ui.style_mut().spacing.interact_size.y = 16.0; + ui.style_mut().spacing.item_spacing.y = 2.0; + ui.style_mut().spacing.button_padding.y = 2.0; + let left = [SchemeSlot::Dk1, SchemeSlot::Lt1, SchemeSlot::Dk2, SchemeSlot::Lt2, SchemeSlot::Hlink, SchemeSlot::FolHlink]; + let right = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; + egui::Grid::new("theme-colors").spacing(vec2(18.0, 4.0)).show(ui, |ui| { + for (a, b) in left.into_iter().zip(right) { + theme_color_row(ui, d, a); + theme_color_row(ui, d, b); + ui.end_row(); + } + }); + ui.add_space(6.0); + let mut name = d.get("schemeName", ""); + ui.horizontal(|ui| { + ui.label("Name:"); + let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); + let shown = name.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert("schemeName".into(), name); + ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + let (ok, cancel) = buttons(ui, "Save"); + if ok { + match theme_color_payload(d) { + Ok(payload) => return app.run("design.colors", payload).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} + +fn seed_theme_fonts(d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); + let major = d.params.get("major").and_then(Value::as_str).unwrap_or("Inter"); + let minor = d.params.get("minor").and_then(Value::as_str).unwrap_or(major); + d.fields.insert("schemeName".into(), name.to_string()); + d.fields.insert("major".into(), major.to_string()); + d.fields.insert("minor".into(), minor.to_string()); + d.fields.insert("seeded".into(), "1".into()); +} + +fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { + let mut v = crate::ribbon::font_families(app); + for scheme in deckcraft_model::theme::builtin_font_schemes() { + v.push(scheme.major.latin); + v.push(scheme.minor.latin); + } + if !selected.is_empty() { + v.push(selected.to_string()); + } + let mut seen = std::collections::HashSet::new(); + v.retain(|face| !face.is_empty() && seen.insert(face.clone())); + v +} + +fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { + let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { + for face in choices { + if ui.selectable_label(selected == face, face).clicked() { + *selected = face.clone(); + } + } + }); + let shown = selected.clone(); + let access = label.to_string(); + resp.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, access.clone()); + info.current_text_value = Some(shown.clone()); + info + }); +} + +fn theme_font_payload(d: &mut Dialog) -> Result { + let name = d.get("schemeName", "").trim().to_string(); + if name.is_empty() { + return Err("Enter a scheme name.".into()); + } + let major = d.get("major", "").trim().to_string(); + if major.is_empty() { + return Err("Choose a heading font.".into()); + } + let minor = d.get("minor", "").trim().to_string(); + if minor.is_empty() { + return Err("Choose a body font.".into()); + } + Ok(json!({"name": name, "major": major, "minor": minor})) +} + +/// Create New Theme Fonts. Returns true when the dialog should close. +fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_theme_fonts(d); + let major = d.get("major", "Inter"); + let minor = d.get("minor", "Inter"); + let headings = theme_font_choices(app, &major); + let bodies = theme_font_choices(app, &minor); + let mut major = major; + let mut minor = minor; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:")); + font_combo(ui, "Heading font", &headings, &mut major); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:")); + font_combo(ui, "Body font", &bodies, &mut minor); + }); + d.fields.insert("major".into(), major.clone()); + d.fields.insert("minor".into(), minor.clone()); + ui.add_space(4.0); + // Theme faces are not egui families; naming an unbound family panics. The lines show the + // chosen names at heading and body sizes. + ui.label(egui::RichText::new(format!("Heading sample {major}")).size(20.0)); + ui.label(egui::RichText::new(format!("Body sample {minor}")).size(14.0)); + ui.add_space(4.0); + let mut name = d.get("schemeName", ""); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:")); + let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); + let shown = name.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert("schemeName".into(), name); + ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + let (ok, cancel) = buttons(ui, "Save"); + if ok { + match theme_font_payload(d) { + Ok(payload) => return app.run("design.fonts", payload).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} + +/// What Insert Hyperlink would edit on the current selection. +#[derive(Clone, Debug, PartialEq)] +enum CurrentLink { + /// Nothing is linked. OK sets a new link (a caret at a word boundary inserts text). + None, + /// One link covers the text span or every selected shape. + One(deckcraft_model::text::Hyperlink), + /// The selection mixes different links. OK must not replace them until a new target is chosen. + Mixed, +} + +/// Fields the Insert Hyperlink dialog opens with. +#[derive(Clone, Debug, PartialEq)] +struct HyperlinkSeed { + /// `url`, `email` or `slide`. + mode: &'static str, + url: String, + email: String, + slide: usize, + tip: String, + highlight: bool, + /// The action is not a web address, email or slide this dialog edits (or the selection mixes + /// links). OK leaves it in place until the user picks a different target. + preserve: bool, +} + +impl HyperlinkSeed { + /// A new link: the address starts at `https://`, as the dialog always did. + fn blank() -> Self { + HyperlinkSeed { mode: "url", url: "https://".into(), email: String::new(), slide: 0, tip: String::new(), highlight: false, preserve: false } + } +} + +/// Turn one hyperlink into dialog fields. A slide that is no longer in the deck, and actions this +/// dialog does not edit (next slide, a program, …), are preserved so OK does not retarget them. +fn seed_of_link(link: &deckcraft_model::text::Hyperlink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + use deckcraft_model::text::Action; + let mut seed = HyperlinkSeed { + mode: "url", + url: String::new(), + email: String::new(), + slide: 0, + tip: link.tooltip.clone(), + highlight: link.highlight_click, + preserve: true, + }; + match &link.action { + Action::Url { url } => { + seed.preserve = false; + if let Some(email) = url.strip_prefix("mailto:") { + seed.mode = "email"; + seed.email = email.to_string(); + seed.url = "https://".into(); + } else { + seed.url = url.clone(); + } + } + Action::Slide { slide } => { + if let Some(i) = slide_ids.iter().position(|id| id == slide) { + seed.preserve = false; + seed.mode = "slide"; + seed.slide = i; + seed.url = "https://".into(); + } + } + _ => {} + } + seed +} + +fn seed_from(current: CurrentLink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + match current { + CurrentLink::None => HyperlinkSeed::blank(), + CurrentLink::Mixed => HyperlinkSeed { preserve: true, url: String::new(), ..HyperlinkSeed::blank() }, + CurrentLink::One(link) => seed_of_link(&link, slide_ids), + } +} + +/// Links on the half-open span `[a, b)`. A point span is a new link: Insert Hyperlink inserts +/// there instead of editing a neighbouring run. +fn link_covering(body: &deckcraft_model::text::TextBody, a: (usize, usize), b: (usize, usize)) -> CurrentLink { + if a == b { + return CurrentLink::None; + } + let mut seen: Option> = None; + for (pi, para) in body.paragraphs.iter().enumerate() { + if pi < a.0 || pi > b.0 { + continue; + } + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + if from >= to { + continue; + } + let mut acc = 0usize; + let mut covered = false; + for run in ¶.runs { + let len = run.char_len(); + let end = acc + len; + if len > 0 && end > from && acc < to { + covered = true; + match &seen { + None => seen = Some(run.props.link.clone()), + Some(prev) if prev == &run.props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + acc = end; + } + if !covered && para.char_len() == 0 { + match &seen { + None => seen = Some(para.end_props.link.clone()), + Some(prev) if prev == ¶.end_props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + } + match seen { + Some(Some(link)) => CurrentLink::One(link), + _ => CurrentLink::None, + } +} + +fn common_click(clicks: impl IntoIterator>) -> CurrentLink { + let mut clicks = clicks.into_iter(); + let Some(first) = clicks.next() else { return CurrentLink::None }; + for next in clicks { + if next != first { + return CurrentLink::Mixed; + } + } + match first { + Some(link) => CurrentLink::One(link), + None => CurrentLink::None, + } +} + +/// The link Insert Hyperlink would edit: text when a caret or selection is active, otherwise the +/// click action shared by the selected shapes. +fn hyperlink_seed_of(st: Option<&deckcraft_engine::DocState>) -> HyperlinkSeed { + let Some(st) = st else { return HyperlinkSeed::blank() }; + let ids: Vec = st.doc.slides.iter().map(|s| s.id).collect(); + let current = if let Some(t) = &st.selection.text { + match deckcraft_engine::cmd::text::body_of(st, t) { + Some(body) => { + let (a, b) = deckcraft_engine::cmd::text::format_range(st, t); + link_covering(body, a, b) + } + None => CurrentLink::None, + } + } else { + common_click(st.selected_shapes().into_iter().map(|sh| sh.click.clone())) + }; + seed_from(current, &ids) +} + +/// OK writes a link unless the dialog is holding an action it does not edit and the user has not +/// chosen a new target. +fn hyperlink_should_apply(seed: &HyperlinkSeed, mode: &str, url: &str, email: &str, slide: usize) -> bool { + if !seed.preserve { + return true; + } + if mode != seed.mode { + return true; + } + match mode { + "email" => email != seed.email, + "slide" => slide != seed.slide, + _ => url != seed.url, + } +} + +fn hyperlink_command(mode: &str, url: &str, email: &str, slide: usize, tip: &str, highlight: bool) -> Value { + match mode { + "slide" => json!({"slide": slide, "tooltip": tip, "highlightClick": highlight}), + "email" => json!({"url": format!("mailto:{email}"), "tooltip": tip, "highlightClick": highlight}), + _ => json!({"url": url, "tooltip": tip, "highlightClick": highlight}), + } +} + +/// Preset percents in the Zoom dialog, largest first. +const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33]; + +/// A typed zoom percent as a scale in `0.1..=4` (10%–400%). `None` when it is not a finite number. +pub fn parse_zoom_percent(text: &str) -> Option { + let t = text.trim().trim_end_matches('%').trim(); + if t.is_empty() { + return None; + } + let v = t.parse::().ok().filter(|n| n.is_finite())?; + Some(((v / 100.0) as f32).clamp(0.1, 4.0)) +} + +/// Zoom dialog: a percent or Fit. Nothing changes until OK. +fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("mode") { + let shown = app.ui.zoom.unwrap_or(app.canvas_scale); + let pct = if shown.is_finite() { (shown * 100.0).round().clamp(10.0, 400.0) } else { 100.0 }; + d.fields.insert("mode".into(), if app.ui.zoom.is_none() { "fit".into() } else { "percent".into() }); + d.fields.insert("percent".into(), format!("{pct:.0}")); + } + let mut mode = d.get("mode", "percent"); + let mut percent = d.get("percent", "100"); + ui.label("Zoom to"); + if ui.radio(mode == "fit", "Fit").clicked() { + mode = "fit".into(); + } + for pct in ZOOM_PRESETS { + let on = mode == "percent" && percent == pct.to_string(); + if ui.radio(on, format!("{pct}%")).clicked() { + mode = "percent".into(); + percent = pct.to_string(); + } + } + ui.horizontal(|ui| { + ui.label("Percent:"); + let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)); + if edit.changed() { + mode = "percent".into(); + } + ui.label("%"); + }); + d.fields.insert("mode".into(), mode.clone()); + d.fields.insert("percent".into(), percent.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + if mode == "fit" { + app.ui.zoom = None; + return true; + } + match parse_zoom_percent(&percent) { + Some(scale) => app.ui.zoom = Some(scale), + None => { + app.set_status("Enter a zoom percent"); + return false; + } + } + } + ok || cancel +} + +/// Seconds for a Trim Media field. Three decimal places, trailing zeros dropped, so the text +/// round-trips the millisecond value the clip stores. +fn ms_as_seconds(ms: u32) -> String { + let s = format!("{:.3}", f64::from(ms) / 1000.0); + let trimmed = s.trim_end_matches('0').trim_end_matches('.'); + if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() } +} + +/// Parse a seconds field back to milliseconds. Blank, junk and negatives become 0; the result is +/// rounded so `0.333` is 333 ms, not 332. +fn seconds_text_to_ms(text: &str) -> u32 { + let v = text.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0); + let ms = (v.max(0.0) * 1000.0).round(); + if ms >= f64::from(u32::MAX) { u32::MAX } else { ms as u32 } +} + +/// Start, end trim, fade in and fade out, as the Trim Media dialog shows them. +struct TrimSeed { + start: String, + end: String, + fade_in: String, + fade_out: String, +} + +impl TrimSeed { + fn zeros() -> Self { + TrimSeed { start: "0".into(), end: "0".into(), fade_in: "0".into(), fade_out: "0".into() } + } + fn from_clip(m: &deckcraft_model::MediaClip) -> Self { + TrimSeed { + start: ms_as_seconds(m.trim_start_ms), + end: ms_as_seconds(m.trim_end_ms), + fade_in: ms_as_seconds(m.fade_in_ms), + fade_out: ms_as_seconds(m.fade_out_ms), + } + } +} + +/// The first selected audio or video. Other shapes in the selection are skipped. No media means +/// the old zeros, which OK then stores. +fn trim_seed(st: Option<&deckcraft_engine::DocState>) -> TrimSeed { + let Some(st) = st else { return TrimSeed::zeros() }; + st.selected_shapes() + .into_iter() + .find_map(|sh| match &sh.kind { + deckcraft_model::ShapeKind::Media(m) => Some(TrimSeed::from_clip(m)), + _ => None, + }) + .unwrap_or_else(TrimSeed::zeros) +} + /// Returns true when the dialog should close. fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let run = |app: &mut SlideApp, id: &str, p: Value| { @@ -148,6 +1024,17 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0)); field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0)); + let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0)); + ui.horizontal(|ui| { + ui.label("Orientation:"); + let portrait = ui.radio(h > w, "Portrait").clicked(); + let landscape = ui.radio(w >= h, "Landscape").clicked(); + // Picking the other orientation swaps the width and height fields. + if portrait && w > h || landscape && h > w { + d.fields.insert("w".into(), format!("{h:.2}")); + d.fields.insert("h".into(), format!("{w:.2}")); + } + }); let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); let (ok, cancel) = buttons(ui, "OK"); if ok { @@ -156,6 +1043,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "sizePosition" => size_position_dialog(app, ui, d), "headerFooter" => { let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); let date = check(ui, d, "Date and time", "date", hf.date); @@ -186,7 +1074,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { apply || apply_all || d.fields.contains_key("cancel") } "hyperlink" => { - let mode = d.get("mode", "url"); + let seed = hyperlink_seed_of(app.session.active()); + let slide_default = seed.slide.to_string(); + let mode = d.get("mode", seed.mode); ui.horizontal(|ui| { if ui.selectable_label(mode == "url", "Web Page or File").clicked() { d.fields.insert("mode".into(), "url".into()); @@ -205,7 +1095,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .active() .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) .unwrap_or_default(); - let sel = d.get("slide", "0").parse::().unwrap_or(0); + let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { for (i, t) in titles.iter().enumerate() { if ui.selectable_label(sel == i, t).clicked() { @@ -214,19 +1104,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } - "email" => field(ui, d, "Email address:", "email", ""), - _ => field(ui, d, "Address:", "url", "https://"), + "email" => field(ui, d, "Email address:", "email", &seed.email), + _ => field(ui, d, "Address:", "url", &seed.url), } - field(ui, d, "ScreenTip:", "tip", ""); + field(ui, d, "ScreenTip:", "tip", &seed.tip); let (ok, cancel) = buttons(ui, "OK"); if ok { - let tip = d.get("tip", ""); - let p = match mode.as_str() { - "slide" => json!({"slide": d.get("slide", "0").parse::().unwrap_or(0), "tooltip": tip}), - "email" => json!({"url": format!("mailto:{}", d.get("email", "")), "tooltip": tip}), - _ => json!({"url": d.get("url", ""), "tooltip": tip}), - }; - run(app, "insert.hyperlink", p); + let mode_now = d.get("mode", seed.mode); + let url = d.get("url", &seed.url); + let email = d.get("email", &seed.email); + let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); + let tip = d.get("tip", &seed.tip); + if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { + run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); + } } ok || cancel } @@ -247,10 +1138,25 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); let na = check(ui, d, "Show without animation", "na", sh.without_animation); let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); + ui.label("Slides"); + let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + d.fields.insert("custom".into(), String::new()); + } + for n in &names { + if ui.selectable_label(&chosen == n, n).clicked() { + d.fields.insert("custom".into(), n.clone()); + } + } + }); let (ok, cancel) = buttons(ui, "OK"); if ok { let ty = d.get("type", "speaker"); - run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut})); + let custom = d.fields.get("custom").cloned().unwrap_or_default(); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); } ok || cancel } @@ -321,18 +1227,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); done } - "zoom" => { - for pct in [400, 200, 150, 100, 75, 66, 50, 33] { - if ui.radio(false, format!("{pct}%")).clicked() { - let _ = app.run("view.zoom", json!({"percent": pct})); - } - } - if ui.button("Fit").clicked() { - app.ui.zoom = None; - } - let (ok, cancel) = buttons(ui, "OK"); - ok || cancel - } + "zoom" => zoom_dialog(app, ui, d), "comment" => { let mut v = d.get("text", ""); ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0)); @@ -344,13 +1239,244 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "bullets" => { + // The current paragraph's list, as the dialog's starting point. + let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); + let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); + let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); + ui.horizontal(|ui| { + for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + let style_key = if tab == "numbered" { "scheme" } else { "char" }; + let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); + let styles: &[(&str, &str)] = if tab == "numbered" { + &[ + ("arabicPeriod", "1. 2. 3."), + ("arabicParenR", "1) 2) 3)"), + ("romanUcPeriod", "I. II. III."), + ("romanLcPeriod", "i. ii. iii."), + ("alphaUcPeriod", "A. B. C."), + ("alphaLcParenR", "a) b) c)"), + ("alphaLcPeriod", "a. b. c."), + ] + } else { + &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] + }; + egui::Grid::new("bullet-styles").show(ui, |ui| { + for (i, (k, l)) in styles.iter().enumerate() { + let r = ui.add_sized( + vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), + egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), + ); + if r.clicked() { + d.fields.insert(style_key.into(), (*k).into()); + } + if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { + ui.end_row(); + } + } + }); + if tab == "bulleted" { + field(ui, d, "Custom character:", "custom", ""); + } else { + field(ui, d, "Start at:", "start", "1"); + } + field(ui, d, "Size (% of text):", "size", "100"); + let colors = [ + ("", "Automatic"), + ("tx1", "Text"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ]; + let color = d.get("color", ""); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + egui::ComboBox::from_id_salt("bullet-color") + .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .show_ui(ui, |ui| { + for (k, l) in colors { + if ui.selectable_label(color == k, l).clicked() { + d.fields.insert("color".into(), k.into()); + } + } + }); + }); + ui.horizontal(|ui| { + if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + d.fields.insert("none".into(), "1".into()); + } + }); + let (ok, cancel) = buttons(ui, "OK"); + let none = d.fields.contains_key("none"); + if none { + run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); + } else if ok { + let mut p = json!({"on": true}); + if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { + p["size"] = json!(size.clamp(25.0, 400.0)); + } + if !color.is_empty() { + p["color"] = json!(color); + } + if tab == "numbered" { + p["scheme"] = json!(d.get("scheme", "arabicPeriod")); + p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); + run(app, "format.numbering", p); + } else { + let custom = d.get("custom", ""); + let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); + p["char"] = json!(ch); + run(app, "format.bullets", p); + } + } + ok || cancel || none + } + "action" => { + use deckcraft_model::text::Action; + let tab = d.get("tab", "click"); + // Start from the selected shape's current setting for this tab. + let (cur, slides, shows) = app + .session + .active() + .map(|st| { + let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); + let slides: Vec<(deckcraft_model::SlideId, String)> = + st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); + let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); + (sh, slides, shows) + }) + .unwrap_or_default(); + let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { + None => ("none", String::new()), + Some(Action::NextSlide) => ("next", String::new()), + Some(Action::PreviousSlide) => ("previous", String::new()), + Some(Action::FirstSlide) => ("first", String::new()), + Some(Action::LastSlide) => ("last", String::new()), + Some(Action::LastViewed) => ("lastViewed", String::new()), + Some(Action::EndShow) => ("endShow", String::new()), + Some(Action::PlayMedia) => ("playMedia", String::new()), + Some(Action::Url { url }) => ("url", url.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), + Some(Action::Program { path }) => ("program", path.clone()), + Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), + }; + ui.horizontal(|ui| { + for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { + if ui.selectable_label(tab == k, l).clicked() && tab != k { + d.fields.clear(); + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + let kind = d.get("kind", cur_kind); + let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); + let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { + if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + }; + pick(d, ui, "none", "None"); + ui.horizontal(|ui| { + if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink { + d.fields.insert("kind".into(), "next".into()); + } + let targets = [ + ("next", "Next Slide"), + ("previous", "Previous Slide"), + ("first", "First Slide"), + ("last", "Last Slide"), + ("lastViewed", "Last Slide Viewed"), + ("endShow", "End Show"), + ("customShow", "Custom Show…"), + ("slide", "Slide…"), + ("url", "URL…"), + ]; + let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + ui.add_enabled_ui(hyperlink, |ui| { + egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + for (k, l) in targets { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + }); + }); + let kind = d.get("kind", cur_kind); + match kind.as_str() { + "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), + "slide" => { + let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, (_, t)) in slides.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("arg".into(), i.to_string()); + } + } + }); + } + "customShow" => { + if shows.is_empty() { + ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + } + let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); + for n in &shows { + if ui.selectable_label(&sel == n, n).clicked() { + d.fields.insert("arg".into(), n.clone()); + } + } + } + _ => {} + } + pick(d, ui, "program", "Run program:"); + if d.get("kind", cur_kind) == "program" { + field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); + } + pick(d, ui, "playMedia", "Play the shape's media"); + let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let kind = d.get("kind", cur_kind); + let arg = d.get("arg", &cur_arg); + let mut p = json!({"on": tab, "action": kind, "highlight": hl}); + match kind.as_str() { + "url" => p["url"] = json!(arg), + "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), + "customShow" => p["show"] = json!(arg), + "program" => p["path"] = json!(arg), + _ => {} + } + run(app, "shape.action", p); + } + ok || cancel + } "altText" => { - let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default(); + let (cur, deco_now) = app + .session + .active() + .map(|s| { + let shapes = s.selected_shapes(); + let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); + let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); + (cur, deco) + }) + .unwrap_or_default(); ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); let mut v = d.get("text", &cur); ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); - let deco = check(ui, d, "Mark as decorative", "deco", false); + let deco = check(ui, d, "Mark as decorative", "deco", deco_now); let (ok, cancel) = buttons(ui, "OK"); if ok { run(app, "shape.altText", json!({"text": v, "decorative": deco})); @@ -382,22 +1508,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - "paragraph" => { - field(ui, d, "Before text (in):", "left", "0"); - field(ui, d, "Special — hanging (in):", "hang", "0"); - field(ui, d, "Spacing before (pt):", "before", "0"); - field(ui, d, "Spacing after (pt):", "after", "0"); - field(ui, d, "Line spacing (lines):", "lines", "1.0"); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "format.paragraph", - json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), - ); - } - ok || cancel - } + "font" => font_dialog(app, ui, d), + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -408,6 +1521,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("JPEG image (current slide)", "jpeg"), ("PowerPoint Presentation (.pptx)", "pptx"), ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), ] { if ui.radio(fmt == f, l).clicked() { d.fields.insert("fmt".into(), f.into()); @@ -451,6 +1565,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } let pdf_params = { let (layout, per) = match pdf_layout.as_str() { "notes" => ("notes", 0), @@ -461,11 +1579,69 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }; let (ok, cancel) = buttons(ui, "Export"); if ok { - let extra = if fmt == "pdf" { pdf_params } else { json!({}) }; + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match fmt.as_str() { + "pdf" => pdf_params, + "gif" => gif_params, + _ => json!({}), + }; app.export_as(&fmt, extra); } ok || cancel } + "replaceFonts" => { + let used: Vec = + app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); + let mut to = d.get("to", ""); + egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + ui.label("Replace:"); + egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + for f in &used { + ui.selectable_value(&mut from, f.clone(), f); + } + }); + ui.end_row(); + ui.label("With:"); + ui.vertical(|ui| { + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + let needle = to.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { + for f in + crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) + { + if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { + to = f.clone(); + } + } + }); + }); + ui.end_row(); + }); + if let Some(msg) = d.fields.get("result") { + ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to.clone()); + let (ok, cancel) = buttons(ui, "Replace"); + if ok && !from.is_empty() && !to.trim().is_empty() { + let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); + let msg = + if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; + d.fields.insert("result".into(), msg); + d.fields.remove("from"); + // Stay open, like PowerPoint, so several fonts can be replaced in a row. + return cancel; + } + cancel + } "symbol" => { ui.label("Click a symbol to insert it at the insertion point."); let groups: [(&str, &str); 6] = [ @@ -509,20 +1685,170 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "pasteSpecial" => { + const OPTS: [(&str, &str); 4] = [ + ("destinationTheme", "Use Destination Theme"), + ("keepSource", "Keep Source Formatting"), + ("picture", "Picture (PNG)"), + ("text", "Keep Text Only"), + ]; + let has_shapes = !app.session.clipboard.shapes.is_empty(); + let mut cur = d.get("as", "destinationTheme"); + ui.label("As:"); + for (id, label) in OPTS { + let ok = has_shapes || matches!(id, "destinationTheme" | "text"); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + } + d.fields.insert("as".into(), cur.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "edit.pasteSpecial", json!({"as": cur})); + } + ok || cancel + } + "find" => find_dialog(app, ui, d), "spelling" => { - let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); + // Words skipped with Ignore All while the dialog is open, one per line. + let ignored = d.get("ignore", ""); + let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); + let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); + let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); + let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); + match info.get("found").and_then(Value::as_str) { + Some(f) if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) + } + _ if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) + } + _ => ui.label( + egui::RichText::new(format!( + "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", + info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder") + )) + .small() + .weak(), + ), + }; if list.is_empty() { ui.label("The spelling check is complete. No issues found."); } - for item in &list { - ui.horizontal(|ui| { + let mut add_ignore = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for item in &list { let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + let slide = item.get("slide").cloned().unwrap_or_default(); + let shape = item.get("shape").cloned().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("edit.select", json!({"ids": [shape]})); + } + if ui.small_button("Ignore All").clicked() { + add_ignore = Some(w.to_string()); + } + if ui.small_button("Add to Dictionary").clicked() + && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) + { + app.set_status(e.to_string()); + } + }); + let sug: Vec = item + .get("suggestions") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) + { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Repeated word").weak()); + if ui.small_button("Delete Repeated Word").clicked() { + let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; + let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; + let text = app.session.active().and_then(|st| { + let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; + let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { + (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { + tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() + } + _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), + }; + Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) + }); + if let Some(gap) = text { + let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); + if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + }); } - }); + if !sug.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Change to:").weak()); + for to in &sug { + let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + if r.clicked() { + let all = ui.input(|i| i.modifiers.shift); + if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + // A word in the notes: change that line of the slide's notes. + let si = slide.as_u64().unwrap_or(0) as usize; + let notes = + app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); + let (pi, st) = ( + item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, + item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, + ); + let lines: Vec = notes + .split('\n') + .enumerate() + .map(|(i, l)| { + if i != pi { + return l.to_string(); + } + let c: Vec = l.chars().collect(); + let n = w.chars().count(); + if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { + c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() + } else { + l.to_string() + } + }) + .collect(); + run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); + } else { + let mut p = if all { + json!({"word": w, "to": to, "all": true}) + } else { + json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) + }; + if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + } + }); + } + } + }); + if let Some(w) = add_ignore { + d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); } let (ok, cancel) = buttons(ui, "Close"); ok || cancel @@ -555,34 +1881,53 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "language" => { + // Starts from the selected text's language and Do Not Check Spelling. + let (cur, np) = selected_language(app); + let lang = d.get("lang", &cur); + let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; ui.label("Mark selected text as:"); - for l in [ - "English (United States)", - "English (United Kingdom)", - "Español", - "Français", - "Deutsch", - "Italiano", - "Português", - "日本語", - "中文", - "العربية", - ] { - let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // The selection's own language first when the list doesn't have it. + let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); + let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); + for (name, tag) in all { + let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); + let label = if installed { format!("{name} (dictionary)") } else { name }; + if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { + d.fields.insert("lang".into(), tag); + } + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); + if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); } let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("lang", &cur); + run(app, "format.language", json!({"lang": lang, "noProof": no_proof})); + } ok || cancel } "customShow" => { let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + let mut started = false; for s in &shows { ui.horizontal(|ui| { ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Show").clicked() { + run(app, "show.start", json!({"custom": s.name})); + started = true; + } if ui.small_button("Delete").clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); } + if started { + return true; + } field(ui, d, "New show name:", "name", "Custom Show 1"); field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); let (ok, cancel) = buttons(ui, "Create"); @@ -646,16 +1991,22 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "trim" => { - field(ui, d, "Start time (s):", "start", "0"); - field(ui, d, "End trim (s):", "end", "0"); - field(ui, d, "Fade in (s):", "fin", "0"); - field(ui, d, "Fade out (s):", "fout", "0"); + let seed = trim_seed(app.session.active()); + field(ui, d, "Start time (s):", "start", &seed.start); + field(ui, d, "End trim (s):", "end", &seed.end); + field(ui, d, "Fade in (s):", "fin", &seed.fade_in); + field(ui, d, "Fade out (s):", "fout", &seed.fade_out); let (ok, cancel) = buttons(ui, "OK"); if ok { run( app, "media.options", - json!({"trimStart": (num(d, "start", 0.0) * 1000.0) as u64, "trimEnd": (num(d, "end", 0.0) * 1000.0) as u64, "fadeIn": (num(d, "fin", 0.0) * 1000.0) as u64, "fadeOut": (num(d, "fout", 0.0) * 1000.0) as u64}), + json!({ + "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), + "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), + "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), + "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), + }), ); } ok || cancel @@ -683,6 +2034,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "Insert"); if ok { run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); } ok || cancel } @@ -708,6 +2060,49 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Close"); ok } + "autocorrect" => { + let pr = &mut app.session.prefs; + ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); + ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); + ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); + ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); + ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); + ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.add_space(4.0); + let mut from = d.get("from", ""); + let mut to = d.get("to", ""); + ui.horizontal(|ui| { + ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)); + ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)); + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); + from.clear(); + to.clear(); + } + }); + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to); + let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); + let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let q = from.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + egui::Grid::new("ac-list").striped(true).show(ui, |ui| { + for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { + ui.label(k); + ui.label(v); + if ui.small_button("Delete").clicked() { + run(app, "text.autocorrect", json!({"remove": k})); + } + ui.end_row(); + } + }); + }); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + "gridOptions" => grid_options(app, ui, d), "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -722,7 +2117,15 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + ui.horizontal(|ui| { + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + if ui.button("AutoCorrect Options…").clicked() { + *d = Dialog::new("autocorrect"); + } + }); + if d.id != "preferences" { + return false; + } ui.horizontal(|ui| { ui.label("Your name (comments):"); ui.text_edit_singleline(&mut app.session.prefs.author); @@ -730,6 +2133,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Done"); ok } + "photoAlbum" => photo_album_dialog(app, ui, d), + "themeColors" => theme_colors_dialog(app, ui, d), + "themeFonts" => theme_fonts_dialog(app, ui, d), + "dateTime" => date_time_dialog(app, ui, d), _ => { ui.label(format!("“{}” is not available yet.", d.id)); let (ok, cancel) = buttons(ui, "OK"); @@ -738,6 +2145,373 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } +fn num_text(v: f64) -> String { + crate::widgets::fmt_num(v, "") +} + +fn field_of(d: &Dialog, key: &str) -> String { + d.fields.get(key).cloned().unwrap_or_default() +} + +fn parse_shown(s: &str) -> Option { + let t = s.trim().trim_end_matches(['%', '"']).trim(); + if t.is_empty() { + return None; + } + t.parse().ok().filter(|v: &f64| v.is_finite()) +} + +fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { + d.fields.insert(key.into(), shown.clone()); + d.fields.insert(format!("seed{key}"), shown.clone()); + d.fields.insert(format!("prev{key}"), shown); + d.fields.insert(format!("{key}Pt"), exact.to_string()); +} + +fn publish_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { + d.fields.insert(key.into(), shown.clone()); + d.fields.insert(format!("seed{key}"), shown); + d.fields.insert(format!("{key}Pt"), exact.to_string()); +} + +fn edited(d: &Dialog, key: &str) -> bool { + field_of(d, key) != field_of(d, &format!("seed{key}")) +} + +fn exact_of(d: &Dialog, key: &str) -> Option { + parse_shown(&field_of(d, &format!("{key}Pt"))) +} + +fn selection_xfrm(app: &SlideApp) -> Option { + let st = app.session.active()?; + let id = *st.selection.shapes.first()?; + let sh = st.shape(id)?; + Some(deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)) +} + +fn seed_size_position(app: &SlideApp, d: &mut Dialog) { + if d.fields.contains_key("baseW") { + return; + } + let num = |k: &str| d.params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let sel = selection_xfrm(app); + let x = num("x").or_else(|| sel.map(|b| b.x)).unwrap_or(0.0); + let y = num("y").or_else(|| sel.map(|b| b.y)).unwrap_or(0.0); + let w = num("w").or_else(|| sel.map(|b| b.w)).unwrap_or(72.0); + let h = num("h").or_else(|| sel.map(|b| b.h)).unwrap_or(72.0); + let rot = num("rot").or_else(|| sel.map(|b| b.rot)).unwrap_or(0.0); + let sw = num("scaleW").unwrap_or(100.0); + let sh = num("scaleH").unwrap_or(100.0); + let lock = d.params.get("lockAspect").and_then(Value::as_bool).unwrap_or(false); + put_dim(d, "x", x, num_text(x / 72.0)); + put_dim(d, "y", y, num_text(y / 72.0)); + put_dim(d, "w", w, num_text(w / 72.0)); + put_dim(d, "h", h, num_text(h / 72.0)); + put_dim(d, "rot", rot, num_text(rot)); + put_dim(d, "scaleW", sw, num_text(sw)); + put_dim(d, "scaleH", sh, num_text(sh)); + d.fields.insert("baseW".into(), w.to_string()); + d.fields.insert("baseH".into(), h.to_string()); + let bit = if lock { "1" } else { "0" }; + d.fields.insert("lock".into(), bit.into()); + d.fields.insert("prevlock".into(), bit.into()); +} + +fn scale_of(pt: f64, base: f64) -> f64 { + if base > 0.0 { pt / base * 100.0 } else { 100.0 } +} + +fn current_points(d: &Dialog, key: &str) -> Option { + if edited(d, key) { parse_shown(&field_of(d, key)).map(|inches| inches * 72.0) } else { exact_of(d, key) } +} + +/// Keep scale tied to size, and the other side tied when aspect is locked. +fn reconcile_size_fields(d: &mut Dialog) { + let lock = field_of(d, "lock") == "1"; + let was_locked = field_of(d, "prevlock") == "1"; + let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); + let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); + let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; + let (w_s, h_s, sw_s, sh_s) = (field_of(d, "w"), field_of(d, "h"), field_of(d, "scaleW"), field_of(d, "scaleH")); + let (prev_w, prev_h, prev_sw, prev_sh) = (field_of(d, "prevw"), field_of(d, "prevh"), field_of(d, "prevscaleW"), field_of(d, "prevscaleH")); + if w_s != prev_w { + if let Some(inches) = parse_shown(&w_s) { + let w_pt = inches * 72.0; + d.fields.insert("wPt".into(), w_pt.to_string()); + let pct = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", pct, num_text(pct)); + if lock { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + let hp = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", hp, num_text(hp)); + } + } + } else if h_s != prev_h { + if let Some(inches) = parse_shown(&h_s) { + let h_pt = inches * 72.0; + d.fields.insert("hPt".into(), h_pt.to_string()); + let pct = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", pct, num_text(pct)); + if lock { + let w_pt = h_pt * ar; + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + let wp = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", wp, num_text(wp)); + } + } + } else if sw_s != prev_sw { + if let Some(pct) = parse_shown(&sw_s) { + let w_pt = base_w * pct / 100.0; + d.fields.insert("scaleWPt".into(), pct.to_string()); + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + if lock { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + publish_dim(d, "scaleH", pct, num_text(pct)); + } + } + } else if sh_s != prev_sh { + if let Some(pct) = parse_shown(&sh_s) { + let h_pt = base_h * pct / 100.0; + d.fields.insert("scaleHPt".into(), pct.to_string()); + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + if lock { + let w_pt = h_pt * ar; + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + publish_dim(d, "scaleW", pct, num_text(pct)); + } + } + } else if lock + && !was_locked + && let Some(w_pt) = current_points(d, "w") + { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + let hp = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", hp, num_text(hp)); + let wp = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", wp, num_text(wp)); + } + for key in ["x", "y", "w", "h", "rot", "scaleW", "scaleH"] { + let v = field_of(d, key); + d.fields.insert(format!("prev{key}"), v); + } + d.fields.insert("prevlock".into(), if lock { "1".into() } else { "0".into() }); +} + +fn shown_points(d: &Dialog, key: &str, label: &str) -> Result { + if edited(d, key) { + let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?; + return Ok(n * 72.0); + } + exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) +} + +fn shown_number(d: &Dialog, key: &str, label: &str) -> Result { + if edited(d, key) { + return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number")); + } + exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) +} + +fn size_position_value(d: &Dialog) -> Result { + let x = shown_points(d, "x", "Horizontal")?; + let y = shown_points(d, "y", "Vertical")?; + let rot = shown_number(d, "rot", "Rotation")?; + let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); + let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); + let mut w = if edited(d, "w") { + shown_points(d, "w", "Width")? + } else if edited(d, "scaleW") { + base_w * shown_number(d, "scaleW", "Scale width")? / 100.0 + } else { + shown_points(d, "w", "Width")? + }; + let mut h = if edited(d, "h") { + shown_points(d, "h", "Height")? + } else if edited(d, "scaleH") { + base_h * shown_number(d, "scaleH", "Scale height")? / 100.0 + } else { + shown_points(d, "h", "Height")? + }; + if field_of(d, "lock") == "1" { + let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; + let width_drives = edited(d, "w") || edited(d, "scaleW") || (!edited(d, "h") && !edited(d, "scaleH")); + if width_drives { + h = if ar > 0.0 { w / ar } else { h }; + } else { + w = h * ar; + } + } + Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"})) +} + +fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { + let mut v = field_of(d, key); + ui.horizontal(|ui| { + ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label)); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)); + }); + d.fields.insert(key.into(), v); +} + +fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_size_position(app, d); + ui.label(egui::RichText::new("Size").strong()); + text_row(ui, d, "Height (in):", "h"); + text_row(ui, d, "Width (in):", "w"); + text_row(ui, d, "Rotation (°):", "rot"); + text_row(ui, d, "Scale height (%):", "scaleH"); + text_row(ui, d, "Scale width (%):", "scaleW"); + check(ui, d, "Lock aspect ratio", "lock", false); + ui.add_space(8.0); + ui.label(egui::RichText::new("Position").strong()); + ui.label("From the top-left corner of the slide"); + text_row(ui, d, "Horizontal (in):", "x"); + text_row(ui, d, "Vertical (in):", "y"); + reconcile_size_fields(d); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + match size_position_value(d) { + Ok(p) => return app.run("shape.sizePosition", p).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} + +const DATE_LANGS: &[(&str, &str)] = &[ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("fr-FR", "French (France)"), + ("ja-JP", "Japanese"), + ("sv-SE", "Swedish"), +]; + +const DATE_KINDS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + +/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only; +/// other languages keep the numeric date and the 24-hour times. +fn date_rows(lang: &str) -> Vec<(&'static str, String)> { + let now = deckcraft_text::datetime::DateTime::now_local(); + let mut rows = Vec::new(); + for kind in DATE_KINDS { + if let Some(now) = now + && let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now) + { + rows.push((kind, sample)); + } + } + if rows.is_empty() { + rows.push(("datetime1", "datetime1".into())); + } + rows +} + +fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(440.0); + ui.label("Available formats:"); + let lang = d.get("language", "en-US"); + let rows = date_rows(&lang); + if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) + && let Some((k, _)) = rows.first() + { + d.fields.insert("format".into(), (*k).to_string()); + } + let format = d.get("format", "datetime1"); + egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(format == *kind, sample).clicked() { + d.fields.insert("format".into(), (*kind).to_string()); + } + } + }); + let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); + ui.horizontal(|ui| { + ui.label("Language:"); + let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { + for (k, l) in DATE_LANGS { + if ui.selectable_label(lang == *k, *l).clicked() { + d.fields.insert("language".into(), (*k).to_string()); + } + } + }); + opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label)); + }); + ui.label( + egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), + ); + let update = check(ui, d, "Update automatically", "update", true); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("language", "en-US"); + let mut format = d.get("format", "datetime1"); + let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7); + if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() { + format = "datetime1".into(); + } + return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok(); + } + cancel +} + +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', @@ -829,3 +2603,981 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +/// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at +/// the caret, else the first run of the first selected shape; US English when none says. +pub fn selected_language(app: &SlideApp) -> (String, bool) { + let Some(st) = app.session.active() else { return ("en-US".into(), false) }; + let run = |sh: &deckcraft_model::Shape, at: Option<(usize, usize)>| -> Option { + let t = sh.text.as_ref()?; + if let Some((p, c)) = at { + let para = t.paragraphs.get(p)?; + let mut n = 0; + for r in ¶.runs { + n += r.char_len(); + if c <= n { + return Some(r.props.clone()); + } + } + return para.runs.last().map(|r| r.props.clone()); + } + t.paragraphs.iter().flat_map(|p| &p.runs).next().map(|r| r.props.clone()) + }; + let props = match &st.selection.text { + Some(t) if !t.notes && t.cell.is_none() => st.shape(t.shape).and_then(|sh| run(sh, Some(t.caret))), + _ => st.selection.shapes.first().and_then(|id| st.shape(*id)).and_then(|sh| run(sh, None)), + }; + let props = props.unwrap_or_default(); + (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) +} + +fn seed_once(d: &mut Dialog, key: &str, val: String) { + d.fields.entry(key.to_string()).or_insert(val); +} + +fn json_text(v: Option<&Value>, default: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()), + Some(Value::Bool(true)) => "1".into(), + Some(Value::Bool(false)) => "0".into(), + _ => default.to_string(), + } +} + +fn color_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(), + _ => String::new(), + } +} + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, ""); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(cur == *v, *l).clicked() { + d.fields.insert((*key).into(), (*v).into()); + } + } + }); +} + +fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { + let mut v = d.get(key, ""); + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); + d.fields.insert(key.into(), v.clone()); + v +} + +fn finite_pt(text: &str) -> Option { + text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( + ui, + |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }, + ); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} + +fn pt_text(v: Option<&Value>, default: f64) -> String { + match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { + Some(n) => crate::widgets::fmt_num(n, ""), + None => crate::widgets::fmt_num(default, ""), + } +} + +fn inches_text(v: Option<&Value>) -> String { + crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") +} + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + let v = json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }); + v +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); + } + } + ok || cancel +} + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} + +#[cfg(test)] +#[path = "font_dialog_tests.rs"] +mod font_dialog_tests; + +#[cfg(test)] +#[path = "size_position_tests.rs"] +mod size_position_tests; + +#[cfg(test)] +#[path = "photo_album_tests.rs"] +mod photo_album_tests; + +/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden +/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. +fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result { + use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS}; + let n = app.session.active().map_or(0, |s| s.doc.slides.len()); + let size = d.get("gifSize", "medium"); + let size_label = |k: &str| match k { + "small" => "Small (640 px wide)", + "large" => "Large (1280 px wide)", + "extraLarge" => "Extra Large (1920 px wide)", + "custom" => "Custom width", + _ => "Medium (960 px wide)", + }; + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); + egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { + for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { + if ui.selectable_label(size == k, size_label(k)).clicked() { + d.fields.insert("gifSize".into(), k.into()); + } + } + }); + }); + if size == "custom" { + field(ui, d, "Width (pixels):", "gifWidth", "960"); + } + field(ui, d, "Seconds per slide:", "gifSeconds", "5"); + let n_text = n.max(1).to_string(); + field(ui, d, "From slide:", "gifFrom", "1"); + field(ui, d, "To slide:", "gifTo", &n_text); + let forever = check(ui, d, "Loop forever", "gifLoop", true); + let hidden = check(ui, d, "Include hidden slides", "gifHidden", false); + + let width = if size == "custom" { + let w = num(d, "gifWidth", 960.0).round(); + if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) { + return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels")); + } + json!(w as u32) + } else { + json!(size) + }; + let seconds = num(d, "gifSeconds", f64::NAN); + if deckcraft_engine::gif_export::check_seconds(seconds).is_err() { + return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}")); + } + let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden}); + let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round()); + if !(from >= 1.0 && from <= to && to <= n as f64) { + return Err(format!("Slides: choose a range from 1 to {n}")); + } + if from > 1.0 || to < n as f64 { + p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::>()); + } + Ok(p) +} + +#[cfg(test)] +#[path = "theme_colors_tests.rs"] +mod theme_colors_tests; + +#[cfg(test)] +#[path = "theme_fonts_tests.rs"] +mod theme_fonts_tests; + +#[cfg(test)] +mod alt_text_tests { + use super::alt_text_starts_decorative; + + #[test] + fn decorative_starts_checked_only_when_every_shape_is() { + assert!(!alt_text_starts_decorative([])); + assert!(!alt_text_starts_decorative([false])); + assert!(alt_text_starts_decorative([true])); + assert!(alt_text_starts_decorative([true, true])); + assert!(!alt_text_starts_decorative([true, false])); + } +} + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Action, Hyperlink, Run, TextBody}; + use deckcraft_model::{Presentation, SlideId}; + + use super::{ + CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from, + seed_of_link, + }; + + fn link(action: Action, tip: &str) -> Hyperlink { + Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } + } + + fn url(address: &str, tip: &str) -> Hyperlink { + let mut h = link(Action::Url { url: address.into() }, tip); + h.highlight_click = tip == "glow"; + h + } + + /// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). + #[test] + fn export_dialog_writes_an_animated_gif() { + use std::cell::RefCell; + + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.gif"); + let picked = path.to_string_lossy().to_string(); + let suggested = std::rc::Rc::new(RefCell::new(String::new())); + let seen = suggested.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |s: &str| { + *seen.borrow_mut() = s.to_string(); + Some(picked.clone()) + })), + ..Default::default() + }; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Animated GIF (.gif)").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif")); + harness.get_by_label("Seconds per slide:"); + harness.get_by_label("Loop forever").click_accesskit(); + harness.run_steps(2); + // A range outside the deck keeps the dialog open with a message, and nothing is written. + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status); + assert!(!path.exists()); + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow()); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_link_opens_as_a_new_web_address() { + let seed = seed_from(CurrentLink::None, &[]); + assert_eq!(seed, HyperlinkSeed::blank()); + assert!(hyperlink_should_apply(&seed, "url", "https://", "", 0)); + } + + #[test] + fn web_email_and_slide_links_fill_the_dialog() { + let ids = [SlideId(7), SlideId(9)]; + let web = seed_of_link(&url("https://example.org", "See"), &ids); + assert_eq!(web.mode, "url"); + assert_eq!(web.url, "https://example.org"); + assert_eq!(web.tip, "See"); + assert!(!web.preserve); + assert!(hyperlink_should_apply(&web, "url", "https://example.org", "", 0)); + + let mail = seed_of_link(&url("mailto:a@b.org", "Write"), &ids); + assert_eq!(mail.mode, "email"); + assert_eq!(mail.email, "a@b.org"); + assert_eq!(mail.tip, "Write"); + assert_eq!(hyperlink_command("email", "", "a@b.org", 0, "Write", false)["url"], "mailto:a@b.org"); + + let slide = seed_of_link(&link(Action::Slide { slide: SlideId(9) }, "There"), &ids); + assert_eq!(slide.mode, "slide"); + assert_eq!(slide.slide, 1); + assert_eq!(slide.tip, "There"); + assert!(!slide.preserve); + + let glow = url("https://example.org", "glow"); + assert!(glow.highlight_click); + assert!(seed_of_link(&glow, &ids).highlight); + assert_eq!(hyperlink_command("url", "https://example.org", "", 0, "glow", true)["highlightClick"], true); + } + + #[test] + fn actions_this_dialog_does_not_edit_survive_ok() { + let next = seed_of_link(&link(Action::NextSlide, "On"), &[]); + assert!(next.preserve); + assert_eq!(next.tip, "On"); + assert_eq!(next.url, ""); + assert!(!hyperlink_should_apply(&next, "url", "", "", 0)); + assert!(hyperlink_should_apply(&next, "url", "https://example.org", "", 0)); + + let gone = seed_of_link(&link(Action::Slide { slide: SlideId(99) }, ""), &[SlideId(1)]); + assert!(gone.preserve, "a missing slide is not retargeted to the first slide"); + assert!(!hyperlink_should_apply(&gone, "url", "", "", 0)); + + let mixed = seed_from(CurrentLink::Mixed, &[]); + assert!(mixed.preserve); + assert!(!hyperlink_should_apply(&mixed, "url", "", "", 0)); + assert!(hyperlink_should_apply(&mixed, "email", "", "a@b.org", 0)); + } + + #[test] + fn a_text_span_reports_the_link_it_covers() { + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + + assert_eq!(link_covering(&body, (0, 6), (0, 6)), CurrentLink::None, "a caret inserts; it does not edit the run beside it"); + match link_covering(&body, (0, 6), (0, 11)) { + CurrentLink::One(h) => assert_eq!(h.tooltip, "tip"), + other => panic!("linked word, got {other:?}"), + } + assert_eq!(link_covering(&body, (0, 0), (0, 11)), CurrentLink::Mixed); + + let mut other = url("https://example.org", "tip"); + other.tooltip = "other".into(); + assert_eq!(common_click([None, None]), CurrentLink::None); + assert!(matches!(common_click([Some(url("https://example.org", "tip"))]), CurrentLink::One(_))); + assert_eq!(common_click([Some(url("https://example.org", "tip")), Some(other)]), CurrentLink::Mixed); + assert_eq!(common_click(std::iter::empty()), CurrentLink::None); + } + + #[test] + fn the_open_dialog_reads_the_caret_word_and_the_shape_click() { + let mut doc = Presentation::default(); + let shape_id = { + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + slide.shapes[0].text = Some(body); + slide.shapes[0].id + }; + let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 8), caret: (0, 8), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.mode, "url"); + assert_eq!(seed.url, "https://example.org"); + assert_eq!(seed.tip, "tip"); + assert!(!seed.preserve); + + st.selection.text = None; + st.selection.shapes = vec![shape_id]; + { + let doc = std::sync::Arc::make_mut(&mut st.doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let shape = slide.shapes.iter_mut().find(|sh| sh.id == shape_id).unwrap(); + let mut click = url("https://shape.example", "shape tip"); + click.highlight_click = true; + shape.click = Some(click); + } + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.url, "https://shape.example"); + assert_eq!(seed.tip, "shape tip"); + assert!(seed.highlight); + + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 11), caret: (0, 11), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed, HyperlinkSeed::blank(), "a caret at the end of the word inserts a new link"); + } +} + +#[cfg(test)] +mod zoom_tests { + use super::parse_zoom_percent; + + fn near(v: f32, want: f32) -> bool { + (v - want).abs() < 1.0e-4 + } + + #[test] + fn zoom_percent_accepts_a_number_and_clamps() { + assert!(parse_zoom_percent("150").is_some_and(|v| near(v, 1.5))); + assert!(parse_zoom_percent("150%").is_some_and(|v| near(v, 1.5))); + assert!(parse_zoom_percent(" 100 ").is_some_and(|v| near(v, 1.0))); + assert!(parse_zoom_percent("1").is_some_and(|v| near(v, 0.1))); + assert!(parse_zoom_percent("900").is_some_and(|v| near(v, 4.0))); + assert!(parse_zoom_percent("").is_none()); + assert!(parse_zoom_percent("fit").is_none()); + assert!(parse_zoom_percent("nan").is_none()); + assert!(parse_zoom_percent("inf").is_none()); + } +} + +#[cfg(test)] +mod trim_media_tests { + use std::sync::Arc; + + use deckcraft_model::{MediaClip, Presentation, ShapeId, ShapeKind}; + + use super::{ms_as_seconds, seconds_text_to_ms, trim_seed}; + + #[test] + fn trim_seconds_round_trip_the_stored_milliseconds() { + for ms in [0, 1, 250, 333, 1000, 1500, 60_000] { + let text = ms_as_seconds(ms); + assert_eq!(seconds_text_to_ms(&text), ms, "{text}"); + } + assert_eq!(seconds_text_to_ms(""), 0); + assert_eq!(seconds_text_to_ms("nope"), 0); + assert_eq!(seconds_text_to_ms("-2"), 0); + assert_eq!(seconds_text_to_ms("1.5"), 1500); + assert_eq!(seconds_text_to_ms("999999999"), u32::MAX); + } + + #[test] + fn trim_dialog_reads_the_selected_clip() { + assert_eq!(trim_seed(None).start, "0"); + let mut doc = Presentation::default(); + let media_id = { + let slide = Arc::make_mut(&mut doc.slides[0]); + let title = slide.shapes[0].id; + let clip = MediaClip { trim_start_ms: 1500, trim_end_ms: 250, fade_in_ms: 333, fade_out_ms: 1000, ..Default::default() }; + let mut media = slide.shapes[0].clone(); + media.id = ShapeId(900); + media.kind = ShapeKind::Media(clip); + slide.shapes.push(media); + (title, ShapeId(900)) + }; + let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); + st.selection.shapes = vec![media_id.0]; + assert_eq!(trim_seed(Some(&st)).start, "0", "a title shape is not a clip"); + st.selection.shapes = vec![media_id.0, media_id.1]; + let seed = trim_seed(Some(&st)); + assert_eq!(seed.start, "1.5"); + assert_eq!(seed.end, "0.25"); + assert_eq!(seed.fade_in, "0.333"); + assert_eq!(seed.fade_out, "1"); + } +} + +#[cfg(test)] +#[path = "spelling_tests.rs"] +mod spelling_tests; + +#[cfg(test)] +#[path = "paragraph_dialog_tests.rs"] +mod paragraph_dialog_tests; diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs new file mode 100644 index 00000000..dfff3d4b --- /dev/null +++ b/crates/ui-egui/src/doc_window.rs @@ -0,0 +1,191 @@ +//! View › New Window: one more window on the same presentation. +//! +//! The window shows that document live, on its own slide, and does not move the editor's +//! selection. It does not edit, and Arrange All is not here. Closing the window leaves the +//! presentation open. + +use deckcraft_render::RenderOpts; +use egui::{Color32, CornerRadius, Rect, RichText, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::SlideApp; + +/// The extra window. `uid` is the presentation it was opened on. +pub struct DocWindow { + pub uid: u64, + pub slide: usize, + key: Option<(u64, u64, usize, u32, u32, bool)>, + tex: Option, +} + +/// `view.newWindow`. `{on: false}` closes it. `{slide}` or `{delta}` moves only this window. +pub fn command(app: &mut SlideApp, p: &Value) -> Result { + if p.get("on").and_then(Value::as_bool) == Some(false) { + app.doc_window = None; + return Ok(json!({"open": false})); + } + let (uid, n, title, editor_slide) = { + let active = app.session.active().ok_or("New Window needs an open presentation")?; + let n = active.doc.slides.len(); + if n == 0 { + return Err("New Window needs a slide".into()); + } + (active.uid, n, window_title(&active.title()), active.selection.slide.min(n - 1)) + }; + let mut slide = app.doc_window.as_ref().filter(|w| w.uid == uid).map(|w| w.slide).unwrap_or(editor_slide); + if let Some(i) = p.get("slide").and_then(Value::as_u64) { + slide = i as usize; + } + if let Some(delta) = p.get("delta").and_then(Value::as_i64) { + slide = shift(slide, delta, n); + } + if slide >= n { + slide = n - 1; + } + match app.doc_window.as_mut().filter(|w| w.uid == uid) { + Some(w) => w.slide = slide, + None => { + app.doc_window = Some(DocWindow { uid, slide, key: None, tex: None }); + } + } + Ok(json!({"open": true, "window": 2, "slide": slide, "slides": n, "title": title})) +} + +pub fn window_title(name: &str) -> String { + format!("{name}:2") +} + +fn shift(slide: usize, delta: i64, n: usize) -> usize { + if n == 0 { + return 0; + } + let last = (n as i64) - 1; + (slide as i64).saturating_add(delta).clamp(0, last) as usize +} + +/// Draw the extra window, or drop it when its presentation has been closed. +pub fn show(app: &mut SlideApp, ctx: &egui::Context) { + let Some(uid) = app.doc_window.as_ref().map(|w| w.uid) else { return }; + let found = + app.session.documents().iter().find(|d| d.uid == uid).map(|d| (d.doc.slides.len(), window_title(&d.title()), d.revision, d.doc.clone())); + let Some((n, title, revision, doc)) = found else { + app.doc_window = None; + return; + }; + if n == 0 { + app.doc_window = None; + return; + } + let slide = app.doc_window.as_ref().map(|w| w.slide.min(n - 1)).unwrap_or(0); + if let Some(w) = app.doc_window.as_mut() { + w.slide = slide; + } + let grayscale = app.ui.grayscale; + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("deckcraft_doc_window"), + egui::ViewportBuilder::default().with_title(title.clone()).with_inner_size([960.0, 640.0]).with_min_inner_size([480.0, 360.0]), + |ui, _| { + let t = crate::theme::Tokens::get(ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + egui::Panel::top("doc_window_bar") + .exact_size(40.0) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(12, 8))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.label(RichText::new(title.clone()).font(crate::theme::bold(13.0)).color(t.text)); + ui.add_space(16.0); + ui.label(RichText::new(format!("Slide {} of {n}", slide + 1)).color(t.text_dim).font(crate::theme::font(13.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled(slide + 1 < n, egui::Button::new("Next")).clicked() { + let _ = app.run("view.newWindow", json!({"delta": 1})); + } + if ui.add_enabled(slide > 0, egui::Button::new("Previous")).clicked() { + let _ = app.run("view.newWindow", json!({"delta": -1})); + } + }); + }); + }); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::same(16))).show(ui, |ui| { + let avail = ui.available_rect_before_wrap(); + let (w, h) = pixel_size(&doc, avail.width()); + let tex = ensure_texture(app, ui.ctx(), &doc, uid, revision, slide, w, h, grayscale); + if let Some(tex) = tex { + let fitted = fit_rect(avail, w, h); + ui.painter().rect_filled(fitted, CornerRadius::ZERO, Color32::WHITE); + ui.painter().image(tex.id(), fitted, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + }); + }); + if ui.input(|i| i.viewport().close_requested()) { + app.doc_window = None; + } + }, + ); +} + +fn pixel_size(doc: &deckcraft_model::Presentation, width_pts: f32) -> (u32, u32) { + let w = (width_pts.round() as u32).clamp(64, 1400); + let aspect = doc.slide_size.height / doc.slide_size.width.max(1.0); + let h = ((w as f64) * aspect).round().clamp(1.0, 1400.0) as u32; + (w, h) +} + +fn fit_rect(avail: Rect, w: u32, h: u32) -> Rect { + let aspect = h as f32 / (w as f32).max(1.0); + let mut width = avail.width(); + let mut height = width * aspect; + if height > avail.height() { + height = avail.height(); + width = height / aspect.max(0.01); + } + Rect::from_center_size(avail.center(), vec2(width.max(1.0), height.max(1.0))) +} + +fn ensure_texture( + app: &mut SlideApp, + ctx: &egui::Context, + doc: &deckcraft_model::Presentation, + uid: u64, + revision: u64, + slide: usize, + w: u32, + h: u32, + grayscale: bool, +) -> Option { + let key = (uid, revision, slide, w, h, grayscale); + if let Some(win) = app.doc_window.as_ref() + && win.key == Some(key) + && let Some(tex) = win.tex.clone() + { + return Some(tex); + } + let img = deckcraft_render::render_slide( + doc, + slide, + &RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() }, + ); + let tex = crate::textures::upload(ctx, "doc-window-slide", crate::textures::to_color_image(&img, grayscale)); + if let Some(win) = app.doc_window.as_mut() { + win.key = Some(key); + win.tex = Some(tex.clone()); + } + Some(tex) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_window_title_marks_the_second_window() { + assert_eq!(window_title("Quarterly review"), "Quarterly review:2"); + } + + #[test] + fn shifting_stops_at_the_ends() { + assert_eq!(shift(0, -1, 3), 0); + assert_eq!(shift(1, 1, 3), 2); + assert_eq!(shift(2, 5, 3), 2); + assert_eq!(shift(0, 1, 0), 0); + } +} diff --git a/crates/ui-egui/src/drawtable_tests.rs b/crates/ui-egui/src/drawtable_tests.rs new file mode 100644 index 00000000..dac1be11 --- /dev/null +++ b/crates/ui-egui/src/drawtable_tests.rs @@ -0,0 +1,81 @@ +//! Draw Table driven like a person: Insert › Table › Draw Table, drag out a table, draw a border +//! across it, then the Table Eraser merges the cells back. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: egui::Pos2, b: egui::Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 10.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn table(h: &Harness<'_, SlideApp>) -> Option<(deckcraft_model::Table, deckcraft_geom::Xfrm)> { + h.state().session.active()?.shapes().iter().find_map(|s| match &s.kind { + deckcraft_model::ShapeKind::Table(t) => Some((t.clone(), s.xfrm?)), + _ => None, + }) +} + +#[test] +fn draw_a_table_split_it_and_erase_the_border() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Insert".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Table").click(); + h.run_steps(2); + h.get_by_label("Draw Table").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + drag(&mut h, at(200.0, 100.0), at(600.0, 300.0)); + let (t, x) = table(&h).expect("the pencil drew a table"); + assert_eq!((t.rows.len(), t.cols.len()), (1, 1)); + assert!((x.w - 400.0).abs() < 3.0 && (x.h - 200.0).abs() < 3.0, "{x:?}"); + + // Still the pencil: a border across, then one down the bottom half only. + drag(&mut h, at(205.0, 180.0), at(610.0, 180.0)); + drag(&mut h, at(350.0, 190.0), at(350.0, 299.0)); + let (t, _) = table(&h).unwrap(); + assert_eq!((t.rows.len(), t.cols.len()), (2, 2), "{t:?}"); + assert_eq!(t.cell(0, 0).unwrap().grid_span, 2, "the top row stays one cell"); + + // The eraser on the vertical border merges the bottom cells again. + let ids: Vec = h.state().session.active().unwrap().shapes().iter().map(|s| s.id.0).collect(); + h.state_mut().session.execute("edit.select", &json!({"ids": ids})).unwrap(); + h.state_mut().ui.tab = "Table Design".into(); + h.run_steps(3); + h.get_by_label("Table Eraser").click(); + h.run_steps(2); + h.hover_at(at(350.0, 250.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(350.0, 250.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at(350.0, 250.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let (t, _) = table(&h).unwrap(); + assert_eq!(t.cell(1, 0).unwrap().grid_span, 2, "{t:?}"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&h).unwrap().0.cell(1, 0).unwrap().grid_span, 1); +} diff --git a/crates/ui-egui/src/effects_tests.rs b/crates/ui-egui/src/effects_tests.rs new file mode 100644 index 00000000..37d856fe --- /dev/null +++ b/crates/ui-egui/src/effects_tests.rs @@ -0,0 +1,45 @@ +//! Design ▸ Effects: the menu applies a theme effect scheme. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn format_name(h: &Harness<'_, SlideApp>) -> String { + h.state().session.active().unwrap().doc.masters[0].theme.format.name.clone() +} + +#[test] +fn effects_menu_applies_a_scheme_once() { + let mut h = harness(); + assert_eq!(format_name(&h), "DeckCraft"); + let n = h.state().session.active().unwrap().history.undo.len(); + h.get_by_label("Effects").click(); + h.run_steps(2); + h.get_by_label("Soft").click(); + h.run_steps(2); + assert_eq!(format_name(&h), "Soft"); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), n + 1); + assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.name, "DeckCraft"); + + h.get_by_label("Effects").click(); + h.run_steps(2); + h.get_by_label("Soft").click(); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), n + 1, "the same scheme is not an undo step"); +} diff --git a/crates/ui-egui/src/eyedropper.rs b/crates/ui-egui/src/eyedropper.rs new file mode 100644 index 00000000..f6483898 --- /dev/null +++ b/crates/ui-egui/src/eyedropper.rs @@ -0,0 +1,150 @@ +//! The Eyedropper in the colour menus (Shape Fill, Shape Outline, Font Color…): choose it, then +//! click the slide to take the colour drawn under the pointer; Esc or a click elsewhere cancels. +//! +//! The menus are drawn by many callers that each apply the colour a swatch gives. The Eyedropper +//! therefore hands its colour back through the same menu: the menu closes while the pointer picks, +//! then opens again for one frame and `color_grid` returns the colour as if its swatch had been +//! clicked, so every caller applies it its own way. + +use egui::{Color32, Context, CursorIcon, Id, Pos2, Rect, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::SlideApp; +use crate::canvas::Xf; +use crate::theme::{self, Tokens}; + +const REQUEST: &str = "deckcraft.eyedropper.request"; +const MENU: &str = "deckcraft.eyedropper.menu"; +const RESULT: &str = "deckcraft.eyedropper.result"; + +/// Pixels per slide unit of the picture the Eyedropper looks at. +const SCALE: f64 = 2.0; + +/// An Eyedropper waiting for a click: the slide as it is drawn and the menu button it came from. +pub struct State { + menu: Id, + slide: usize, + image: deckcraft_render::Image, +} + +fn key(name: &str) -> Id { + Id::new(name) +} + +/// The colour menu button whose menu is open (noted by `color_popup` every frame it is). +pub fn note_open_menu(ctx: &Context, button: Id) { + ctx.data_mut(|d| d.insert_temp(key(MENU), button)); +} + +/// The menu button that is open now, if a menu noted itself. +pub fn open_menu(ctx: &Context) -> Option { + ctx.data(|d| d.get_temp::(key(MENU))) +} + +/// Keeps `hex` (`RRGGBB`) for the menu on `button`, which opens to hand it to its caller. +pub fn store_result(ctx: &Context, button: Id, hex: &str) { + ctx.data_mut(|d| d.insert_temp(key(RESULT), Some((button, hex.to_string())))); +} + +/// "Eyedropper" was chosen in the open menu. +pub fn request(ctx: &Context) { + ctx.data_mut(|d| d.insert_temp(key(REQUEST), true)); +} + +/// Has the Eyedropper taken a colour for the menu on `button`? +pub fn result_waits_for(ctx: &Context, button: Id) -> bool { + ctx.data(|d| d.get_temp::>(key(RESULT))).flatten().is_some_and(|(b, _)| b == button) +} + +/// The colour the Eyedropper took (`RRGGBB`), once. +pub fn take_result(ctx: &Context) -> Option { + let r = ctx.data(|d| d.get_temp::>(key(RESULT))).flatten(); + if r.is_some() { + ctx.data_mut(|d| d.insert_temp(key(RESULT), None::<(Id, String)>)); + } + r.map(|(_, hex)| hex) +} + +/// Once a frame: start the Eyedropper when a menu asked for it, stop it when something else is +/// clicked. +pub fn tick(app: &mut SlideApp, ctx: &Context) { + if ctx.data_mut(|d| d.remove_temp::(key(REQUEST))).unwrap_or(false) { + begin(app, ctx); + } + if app.eyedropper.is_some() { + let outside = + ctx.input(|i| i.pointer.primary_pressed() && i.pointer.hover_pos().is_some_and(|p| app.canvas_rect.is_none_or(|c| !c.contains(p)))); + if outside { + app.eyedropper = None; + } + } +} + +fn begin(app: &mut SlideApp, ctx: &Context) { + let Some(menu) = open_menu(ctx) else { return }; + let Some(d) = app.session.active() else { return }; + if d.selection.target != deckcraft_engine::Target::Slides { + app.set_status("The Eyedropper takes colours from a slide"); + return; + } + let slide = d.selection.slide; + let image = deckcraft_render::render_slide(&d.doc, slide, &deckcraft_render::RenderOpts { scale: SCALE, ..Default::default() }); + app.eyedropper = Some(State { menu, slide, image }); + app.set_status("Eyedropper: click the slide (Esc cancels)"); +} + +fn hex(c: [u8; 4]) -> String { + format!("{:02X}{:02X}{:02X}", c[0], c[1], c[2]) +} + +/// The slide canvas while the Eyedropper waits: the pointer is a crosshair with the colour under it +/// beside it, a click takes it. Returns true when it used the pointer (the tools get none). +pub fn canvas(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) -> bool { + let Some(state) = app.eyedropper.as_ref() else { return false }; + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if escape || resp.secondary_clicked() { + app.eyedropper = None; + return true; + } + let Some(pos) = resp.hover_pos() else { return true }; + let on_slide = app.slide_rect.is_some_and(|r| r.contains(pos)); + if !on_slide { + ui.ctx().set_cursor_icon(CursorIcon::NotAllowed); + if resp.clicked() { + app.eyedropper = None; + } + return true; + } + ui.ctx().set_cursor_icon(CursorIcon::Crosshair); + let p = xf.to_slide(pos); + let under = state.image.straight_at((p.x * SCALE).max(0.0) as u32, (p.y * SCALE).max(0.0) as u32); + if let Some(c) = under { + loupe(ui, pos, c); + } + if resp.clicked() { + let (menu, slide) = (state.menu, state.slide); + app.eyedropper = None; + match app.session.execute("slide.colorAt", &serde_json::json!({"slide": slide, "x": p.x, "y": p.y, "scale": SCALE})) { + Ok(v) => { + if let Some(color) = v["color"].as_str() { + store_result(ui.ctx(), menu, color); + } + } + Err(e) => app.set_status(e.to_string()), + } + ui.ctx().request_repaint(); + } + true +} + +/// The colour under the pointer, drawn beside it with its hex. +fn loupe(ui: &Ui, pos: Pos2, c: [u8; 4]) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, key("deckcraft.eyedropper.loupe"))); + let at = pos + vec2(18.0, 18.0); + let swatch = Rect::from_min_size(at, vec2(64.0, 26.0)); + painter.rect(swatch, 3, Color32::from_rgb(c[0], c[1], c[2]), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + painter.rect_stroke(swatch.expand(2.0), 3, Stroke::new(1.0, t.border), StrokeKind::Outside); + let label = Rect::from_min_size(pos2(at.x, swatch.max.y + 5.0), vec2(64.0, 16.0)); + painter.rect_filled(label, 3, Color32::WHITE); + painter.text(label.center(), egui::Align2::CENTER_CENTER, format!("#{}", hex(c)), theme::bold(11.0), Color32::BLACK); +} diff --git a/crates/ui-egui/src/eyedropper_tests.rs b/crates/ui-egui/src/eyedropper_tests.rs new file mode 100644 index 00000000..245c4be2 --- /dev/null +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -0,0 +1,113 @@ +//! The Eyedropper on the slide canvas through real egui input. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::eyedropper::{note_open_menu, request, result_waits_for, take_result}; +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut session = Session::with_new(); + session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + session.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + session.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + h.run_steps(4); + h +} + +fn menu() -> Id { + Id::new("test-menu") +} + +/// A colour menu asked for the Eyedropper. +fn ask(h: &mut Harness<'static, SlideApp>) { + note_open_menu(&h.ctx, menu()); + request(&h.ctx); + h.run_steps(3); +} + +/// Where a point of the slide is on screen. +fn at(h: &Harness<'static, SlideApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let r = app.slide_rect.expect("the slide is drawn"); + r.min + egui::vec2(x, y) * app.canvas_scale +} + +fn click(h: &mut Harness<'static, SlideApp>, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); +} + +fn selection(h: &Harness<'static, SlideApp>) -> Vec { + h.state().session.doc().unwrap().selection.shapes.iter().map(|s| s.0).collect() +} + +#[test] +fn a_click_on_the_slide_takes_the_colour_under_it_and_nothing_else_happens() { + let mut h = harness(); + let before = selection(&h); + let revision = h.state().session.doc().unwrap().revision; + ask(&mut h); + assert!(h.state().eyedropper.is_some(), "asking starts the Eyedropper"); + // A hover shows the colour; nothing is chosen yet. + let on_shape = at(&h, 150.0, 150.0); + h.input_mut().events.push(Event::PointerMoved(on_shape)); + h.run_steps(3); + assert!(h.state().eyedropper.is_some() && !result_waits_for(&h.ctx, menu())); + click(&mut h, on_shape); + assert!(h.state().eyedropper.is_none(), "one click ends it"); + assert!(result_waits_for(&h.ctx, menu()), "the colour waits for the menu that asked"); + assert_eq!(take_result(&h.ctx).as_deref(), Some("FF0000"), "the red of the shape under the pointer"); + assert_eq!(take_result(&h.ctx), None, "taken once"); + assert_eq!(selection(&h), before, "the click did not select or move anything"); + // The slide itself is untouched, and the next click is an ordinary click again. + assert_eq!(h.state().session.doc().unwrap().revision, revision, "the deck is untouched"); + // Beside the shape the same click takes the slide's background instead. + ask(&mut h); + let empty = at(&h, 700.0, 450.0); + click(&mut h, empty); + let bg = take_result(&h.ctx).expect("a colour"); + assert_ne!(bg, "FF0000"); +} + +#[test] +fn escape_and_a_click_off_the_slide_take_nothing() { + let mut h = harness(); + ask(&mut h); + let on_shape = at(&h, 150.0, 150.0); + h.input_mut().events.push(Event::PointerMoved(on_shape)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().eyedropper.is_none(), "Esc cancels"); + assert_eq!(take_result(&h.ctx), None); + + ask(&mut h); + let off = h.state().slide_rect.unwrap().min - egui::vec2(10.0, 10.0); + click(&mut h, off); + assert!(h.state().eyedropper.is_none(), "a click beside the slide cancels"); + assert_eq!(take_result(&h.ctx), None); + + // Without an open colour menu there is nothing to hand a colour to. + let mut other = harness(); + request(&other.ctx); + other.run_steps(3); + assert!(other.state().eyedropper.is_none()); +} diff --git a/crates/ui-egui/src/font_dialog_tests.rs b/crates/ui-egui/src/font_dialog_tests.rs new file mode 100644 index 00000000..06ccfc02 --- /dev/null +++ b/crates/ui-egui/src/font_dialog_tests.rs @@ -0,0 +1,65 @@ +//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + // A selected shape, not a caret: the dialog then formats the text that is already there. + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn bold_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.bold) +} + +#[test] +fn font_button_applies_bold_from_the_dialog() { + let mut h = harness(); + h.get_by_label("Font…").click(); + // The opening frame ignores the click that summoned the window; the next one is live. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font")); + // A pointer click on this checkbox does not register through kittest's per-event frames; + // the accessibility click does, and it is the same toggle a pointer click runs. + h.get_by_label("Bold").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(bold_of(&h), Some(true)); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…"); +} + +#[test] +fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status); + h.get_by_label("Cancel").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(bold_of(&h), None); +} diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs new file mode 100644 index 00000000..4180e503 --- /dev/null +++ b/crates/ui-egui/src/guides_tests.rs @@ -0,0 +1,69 @@ +//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one +//! moves it (with its distance from the centre shown), dropping it off the slide deletes it. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().run("view.guides", json!({"on": true})).unwrap(); + h.run_steps(2); + let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone(); + assert_eq!(guides(&h).len(), 2, "the two centre guides"); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + // Grab the vertical guide away from the horizontal one and drag it left. + let start = at(480.0, 100.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0))); + h.run_steps(1); + } + assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0))); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/guides.png"); + } + h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h)[0].pos, 300.0); + + // Off the top of the slide with the horizontal guide: deleted. + let g = at(700.0, 270.0); + h.hover_at(g); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h).len(), 1); + assert!(guides(&h)[0].vertical); + + // Hidden guides stay in the file but aren't drawn or grabbed. + h.state_mut().run("view.guides", json!({"on": false})).unwrap(); + h.run_steps(2); + assert!(!h.state().session.tool.guides_visible); + assert_eq!(guides(&h).len(), 1); +} diff --git a/crates/ui-egui/src/huge_deck_tests.rs b/crates/ui-egui/src/huge_deck_tests.rs new file mode 100644 index 00000000..b22cb400 --- /dev/null +++ b/crates/ui-egui/src/huge_deck_tests.rs @@ -0,0 +1,39 @@ +//! Large presentations in the editor: views that list every slide lay out only what is in view. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(n: usize) -> egui_kittest::Harness<'static, SlideApp> { + let mut s = Session::with_new(); + for _ in 1..n { + s.execute("slide.new", &json!({})).unwrap(); + } + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ) +} + +/// Outline view of 2000 slides lays out the slides in view (and the current one), not all 2000 +/// title editors every frame (40 ms a frame in a release build, growing with the text). +#[test] +fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() { + let mut h = harness(2000); + h.state_mut().run("view.outline", json!({})).unwrap(); + h.run_steps(3); + let editors = h.query_all_by_role(Role::TextInput).count(); + assert!(editors > 5 && editors < 200, "{editors} text editors laid out"); + // The current slide is always laid out, wherever it is. + h.state_mut().run("slide.go", json!({"index": 1500})).unwrap(); + h.run_steps(3); + assert!(h.query_all_by_role(Role::TextInput).count() < 200); + assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500); +} diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 911a2d1a..c9bc7dab 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -132,14 +132,14 @@ fn korean_composes_in_the_notes_pane() { for pre in ["ㄴ", "노", "놑", "노트"] { h.event(preedit(pre)); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one"); + assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one"); } let ime = h.output().platform_output.ime.expect("the notes field turns the input method on"); let slide = h.state().slide_rect.unwrap(); assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide"); h.event(Event::Ime(ImeEvent::Commit("노트".into()))); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, "노트"); + assert_eq!(h.state().notes_buf.2.text, "노트"); // Focus leaves the field (Escape): the notes are committed as one step. h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/src/inbox_tests.rs b/crates/ui-egui/src/inbox_tests.rs new file mode 100644 index 00000000..dc657173 --- /dev/null +++ b/crates/ui-egui/src/inbox_tests.rs @@ -0,0 +1,188 @@ +//! #414: a browser file pick finishes on the document, slide and operation that started it. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use deckcraft_model::ShapeKind; +use serde_json::json; + +use super::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; +use crate::Session; + +fn png() -> Vec { + // 1×1 RGB PNG. Enough for `insert.picture` to accept it. + const HEX: &str = + "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63f8cfc0000003010100c9fe92ef0000000049454e44ae426082"; + (0..HEX.len()).step_by(2).map(|i| u8::from_str_radix(&HEX[i..i + 2], 16).expect("hex")).collect() +} + +fn wav() -> Vec { + let data = [0u8; 8]; + let mut b = b"RIFF".to_vec(); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8000u32.to_le_bytes()); + b.extend_from_slice(&16000u32.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b +} + +fn setup() -> (SlideApp, Inbox, Arc>>) { + let inbox: Inbox = Inbox::default(); + let started = Arc::new(Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + inbox: Some(inbox.clone()), + open_async: Some(Box::new(move |purpose, id| { + record.lock().expect("request log").push((purpose.to_string(), id)); + })), + ..Default::default() + }; + (SlideApp::new(Session::with_new(), services), inbox, started) +} + +fn deliver(inbox: &Inbox, id: u64, name: &str, bytes: Vec) { + inbox.lock().expect("inbox").push(InboxEvent::File(InboxFile { id, name: name.into(), bytes })); +} + +fn pictures(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count() +} + +fn audios(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Media(ref m) if !m.video)).count() +} + +/// Two picks finish in reverse order, after the user has changed slide and document. +/// Each file uses the operation that was requested and lands on the slide that asked. +#[test] +fn late_picker_files_keep_their_document_slide_and_operation() { + let (mut app, inbox, started) = setup(); + let uid_a = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + assert_ne!(slide0, slide1); + + // Audio was asked for on slide 1; the picture and the outline on slide 0. + app.pick_and_insert("audio", "insert.audio"); + app.session.execute("slide.go", &json!({"id": slide0.0})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + app.pick_and_open(); + + app.session.execute("file.new", &json!({})).unwrap(); + let uid_b = app.session.active().unwrap().uid; + assert_ne!(uid_a, uid_b); + let shapes_b = app.session.active().unwrap().doc.slides[0].shapes.len(); + // A video was requested on B. Delivering a PNG must not insert a picture anywhere. + app.pick_and_insert("video", "insert.video"); + + let log = started.lock().expect("request log").clone(); + assert_eq!(log.iter().map(|(p, _)| p.as_str()).collect::>(), vec!["audio", "picture", "open", "video"]); + let audio_id = log[0].1; + let picture_id = log[1].1; + let outline_id = log[2].1; + let video_id = log[3].1; + + // The video request completes first, while B is still current. A PNG must not become a picture. + deliver(&inbox, video_id, "clip.mp4", png()); + app.drain_inbox(); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("not a supported video") || status.contains("no video"), "{status}"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b); + + // The earlier picks then finish in reverse order. + deliver(&inbox, outline_id, "talk.md", b"Brought Later\n".to_vec()); + deliver(&inbox, picture_id, "shot.png", png()); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + + assert_eq!(app.session.active().unwrap().uid, uid_b, "the view stays on the document the user switched to"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b, "the video request must not insert the PNG here"); + assert_eq!(pictures(&app, uid_b, app.session.documents().iter().find(|d| d.uid == uid_b).unwrap().doc.slides[0].id), 0); + + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid_a, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid_a, slide1), 0); + assert_eq!(audios(&app, uid_a, slide1), 1); + assert_eq!(audios(&app, uid_a, slide0), 0); + let a = app.session.documents().iter().find(|d| d.uid == uid_a).unwrap(); + assert!(a.doc.slides.iter().any(|s| s.title() == "Brought Later"), "the outline stays on the document that opened it"); + assert_eq!(a.doc.slides.len(), 3); + // The user had moved to slide 0 of A before switching documents; B is what's on screen. + assert_eq!(app.session.active().unwrap().selection.slide, 0); +} + +/// Closing the presentation, or dismissing the picker, drops the file instead of inserting +/// it into whatever is open now. +#[test] +fn a_closed_or_cancelled_request_does_not_insert_into_the_active_document() { + let (mut app, inbox, started) = setup(); + let slide = app.session.active().unwrap().current_slide().unwrap().id; + app.pick_and_insert("picture", "insert.picture"); + app.session.execute("slide.new", &json!({})).unwrap(); + let kept = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.delete", &json!({"ids": [slide.0]})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + let log = started.lock().expect("request log").clone(); + let deleted_slide = log[0].1; + let dismiss = log[1].1; + + inbox.lock().expect("inbox").push(InboxEvent::Cancelled { id: dismiss }); + deliver(&inbox, dismiss, "late.png", png()); + deliver(&inbox, deleted_slide, "gone.png", png()); + // An id that was never started must not insert either. + deliver(&inbox, 999, "stray.png", png()); + app.drain_inbox(); + + assert_eq!(pictures(&app, app.session.active().unwrap().uid, kept), 0); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("deleted"), "{status}"); + + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + // The deleted-slide request was already consumed. Start another and close before it arrives. + let closed = app.session.active().unwrap().uid; + app.pick_and_insert("audio", "insert.audio"); + let audio_id = started.lock().expect("request log").last().unwrap().1; + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + let other = app.session.active().unwrap().uid; + assert_ne!(closed, other); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + let st = app.session.documents().iter().find(|d| d.uid == other).unwrap(); + let n = st.doc.slides.iter().flat_map(|s| s.shapes.iter()).filter(|s| matches!(s.kind, ShapeKind::Media(_))).count(); + assert_eq!(n, 0, "a file for a closed presentation is not inserted into the one now open"); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("closed"), "{status}"); +} + +/// A drop is bound to the slide under the pointer at drop time, not the slide current when +/// the bytes have been read. +#[test] +fn a_dropped_file_stays_on_the_slide_it_was_dropped_on() { + let (mut app, inbox, _) = setup(); + let uid = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + let id = app.begin_external_file("drop"); + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + deliver(&inbox, id, "shot.png", png()); + app.drain_inbox(); + assert_eq!(app.session.active().unwrap().current_slide().unwrap().id, slide1, "the view stays on the slide the user moved to"); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid, slide1), 0); +} diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/inktoshape_tests.rs new file mode 100644 index 00000000..7a7ebafc --- /dev/null +++ b/crates/ui-egui/src/inktoshape_tests.rs @@ -0,0 +1,60 @@ +//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the +//! mouse, press the button, get a rectangle shape. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn pen_a_rectangle_then_ink_to_shape() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Draw".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Black Pen").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)]; + h.hover_at(at(200.0, 150.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for w in corners.windows(2) { + for i in 1..=12 { + let t = i as f32 / 12.0; + let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + } + h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let has_ink = |h: &Harness<'_, SlideApp>| { + h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })) + }; + assert!(has_ink(&h), "the pen drew a stroke"); + h.get_by_label("Ink to Shape").click(); + h.run_steps(3); + assert!(!has_ink(&h), "the only stroke became a shape"); + let st = h.state().session.active().unwrap(); + let sh = st.shapes().last().unwrap(); + assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom); + let x = sh.xfrm.unwrap(); + assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}"); + assert_eq!(st.selection.shapes, vec![sh.id]); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ink_to_shape.png"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..f020f5f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,20 +6,29 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod animtimeline; pub mod backstage; pub mod canvas; pub mod control; pub mod credits; pub mod dialogs; +pub mod doc_window; +pub mod eyedropper; pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod notes; +pub mod opening; pub mod panes; +mod preview; +pub mod qat; pub mod ribbon; pub mod selpane; +pub mod settings; pub mod show; pub mod sorter; +pub mod squiggles; pub mod status; pub mod textures; pub mod theme; @@ -28,9 +37,11 @@ pub mod toast; pub mod uifonts; pub mod widgets; +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; -use deckcraft_engine::{Mods, Session, UiRequest}; +use deckcraft_engine::{Mods, Session, Target, UiRequest}; +use deckcraft_model::{LayoutId, MasterId, SlideId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -39,10 +50,152 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; -pub type OpenAsyncFn = Box; +/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`] +/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is +/// not applied to whichever document happens to be active. +pub type OpenAsyncFn = Box; pub type DownloadFn = Box; -/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc)>>>; + +/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. +#[derive(Clone, Debug)] +pub struct InboxFile { + /// Cancellation identity. A file whose id was cancelled, or never started, is ignored. + pub id: u64, + pub name: String, + pub bytes: Vec, +} + +/// What the host delivers through [`Inbox`] after an asynchronous read. +#[derive(Clone, Debug)] +pub enum InboxEvent { + File(InboxFile), + /// The picker was dismissed or the file could not be read. `id` will not be applied. + Cancelled { + id: u64, + }, +} + +/// Files delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc>>; + +/// The operation the user asked for. Kept with the request so a late completion does not +/// guess a command from the file name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePurpose { + Open, + Picture, + Audio, + Video, + /// Change Picture: the picture replaces the selected one (`picture.change`). + ChangePicture, + /// A drop, or any other purpose: decide from the name and bytes. + Infer, +} + +/// Where in a presentation a file should land. Ids, not indices: slides can move while +/// the browser is still reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePlace { + Slide(SlideId), + Master(MasterId), + Layout { + master: MasterId, + layout: LayoutId, + }, + /// The handout or notes master (one per presentation, so no id is needed). + NotesPage(Target), + /// The document was open but had no current slide, master or layout. + Unspecified, +} + +/// The presentation (and place in it) captured when the request started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileTarget { + /// Nothing was open. An insert makes its own document rather than using one opened + /// while the file was being read. + Absent, + Document { + doc: u64, + place: FilePlace, + }, +} + +struct PendingFile { + id: u64, + purpose: FilePurpose, + target: FileTarget, +} + +struct LocatedPlace { + target: Target, + slide: Option, +} + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + FilePurpose::Picture => Some("insert.picture"), + FilePurpose::Audio => Some("insert.audio"), + FilePurpose::Video => Some("insert.video"), + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), + FilePurpose::Open => open_command(name), + FilePurpose::Infer => infer_command(name, bytes), + }; + cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) +} + +/// How long a transient status message stays visible. +const STATUS_LINGER_MS: f64 = 6000.0; /// Platform services injected by the host (desktop or web). #[derive(Default)] @@ -53,13 +206,18 @@ pub struct Services { pub pick_save: Option, pub read: Option, pub write: Option, - /// Asynchronous open dialog (web): the chosen file arrives through `inbox`. + /// Asynchronous open dialog (web). The callback receives the purpose and the request id + /// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`. pub open_async: Option, /// Hand bytes to the user as a named file (browser download). pub download: Option, pub inbox: Option, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option Option>>>, + /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). + pub clipboard_html: Option Option>>, + /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. + pub clipboard_set_html: Option bool>>, /// Audio output for media playback (cpal on desktop); without one media plays silently. pub audio_out: Option>, } @@ -86,11 +244,14 @@ pub struct UiState { pub notes: bool, pub notes_height: f32, pub thumbs_width: f32, - /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`. + /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, + /// `smartart` (Text Pane). pub pane: Option, pub pane_width: f32, pub ruler: bool, pub gridlines: bool, + /// Slides per page previewed in Handout Master view (1, 2, 3, 4, 6 or 9). + pub handout_per_page: u8, pub guides: bool, pub smart_guides: bool, /// None = fit to window. @@ -101,7 +262,18 @@ pub struct UiState { pub ui_scale: f32, /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). pub format_tab: String, + /// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it). + pub smartart_row: usize, + /// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list). + pub smartart_focus: Option, + /// The Text Pane's line drafts, for the SmartArt group they were read from. + pub smartart_drafts: (u32, Vec), pub grayscale: bool, + /// Comment the Review Previous/Next buttons last landed on: `(slide, index)`. + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_focus: Option<(usize, usize)>, + /// Quick Access Toolbar buttons, left to right. + pub qat: Vec, } impl Default for UiState { @@ -118,6 +290,7 @@ impl Default for UiState { pane_width: 300.0, ruler: false, gridlines: false, + handout_per_page: 6, guides: false, smart_guides: true, zoom: None, @@ -126,11 +299,62 @@ impl Default for UiState { presenter_view: true, ui_scale: 1.0, format_tab: "fill".into(), + smartart_row: 0, + smartart_focus: None, + smartart_drafts: (0, vec![]), grayscale: false, + comment_focus: None, + qat: qat::defaults(), } } } +/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) +/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the +/// shortcut never reached the session and only the context menu could paste objects (#182). An +/// invisible word joiner, so a paste into another app adds nothing visible. +const OBJECTS_MARKER: &str = "\u{2060}"; + +/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. +fn system_clipboard_text(t: &str) -> String { + if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } +} + +/// The editor's zoom range (1 = 100 %). +pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; + +impl UiState { + /// The state with every value usable: `ui.json` is a file on disk, and a damaged or + /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the + /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are + /// brought into range the same way, and the recent list is cut to its usual length. + pub fn sanitized(mut self) -> UiState { + let d = UiState::default(); + let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); + self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); + self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); + self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); + self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); + self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); + let short = |s: &String| s.len() <= 64; + if !short(&self.tab) { + self.tab = d.tab; + } + if !short(&self.format_tab) { + self.format_tab = d.format_tab; + } + self.pane = self.pane.filter(short); + self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); + self.recent.dedup(); + self.recent.truncate(RECENT_MAX); + self + } +} + +/// How many recent files File › Open and Home list. +pub const RECENT_MAX: usize = 12; + /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -142,32 +366,51 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.ruler", "Ruler", None, "{on?: bool}"), ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.gridOptions", "Grid Options…", None, "{spacing?: pt, snap?: bool, smartGuides?: bool, gridlines?: bool, guides?: bool}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), - ("view.zoom", "Zoom…", None, "{percent}"), + ("view.zoom", "Set Zoom", None, "{percent}"), + ("view.zoomDialog", "Zoom…", None, "{}"), ("view.fit", "Fit to Window", None, "{}"), - ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), + ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"), + ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), ("view.grayscale", "Grayscale", None, "{on?: bool}"), - ("show.start", "Slide Show", Some("F5"), "{from?: index}"), + ("view.newWindow", "New Window", None, "{on?: bool, slide?: index, delta?: int}"), + ("show.start", "Slide Show", Some("F5"), "{from?: index, custom?: name}"), ("show.presenter", "Presenter View", None, "{on?: bool}"), ("show.end", "End Show", None, "{}"), + ("animation.preview", "Preview Animations", None, "{on?: bool}"), ("app.home", "Home", None, "{on?}"), ("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"), ("app.closePresentation", "Close", Some("Cmd+W"), "{}"), - ("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"), + ( + "app.print", + "Print", + Some("Cmd+P"), + "{layout?, perPage?, slides?, includeHidden?, frame?, paper?: letter|a4|legal|a3, orientation?: portrait|landscape}", + ), ("app.about", "About DeckCraft", None, "{}"), ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), + ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), + ("app.changePictureDialog", "Change Picture…", None, "{}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), + ("app.reuseSlidesDialog", "Reuse Slides…", None, "{keepFormatting?: bool}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), + ("app.browseThemeDialog", "Browse for Themes…", None, "{}"), + ("app.compareDialog", "Compare…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), + ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), + ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -177,17 +420,39 @@ pub struct Perf { pub fps: f64, } +/// Playhead for the in-canvas animation preview. +struct AnimPreview { + uid: u64, + slide: usize, + /// `Arc` address of the slide when preview started. An edit replaces the slide and stops playback. + slide_ptr: usize, + /// egui time when the first frame latched the clock (`None` until then). + started: Option, + elapsed: f64, + timeline: deckcraft_anim::Timeline, +} + pub struct SlideApp { + /// Table Design's border pen (`color`, `width`, `dash` for `table.borders`). + pub table_pen: serde_json::Value, pub session: Session, pub services: Services, pub ui: UiState, pub textures: textures::Textures, pub control_rx: Option>, + /// Spelling squiggles, checked on a worker thread. + pub squiggles: squiggles::Squiggles, pub dialog: Option, + /// The word looked up in the Thesaurus pane. + pub thesaurus_query: String, pub show: Option, + /// Current slide's animations playing on the editor canvas (not the full-screen show). + anim_preview: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, + /// A presentation being read in the background, with its progress window ([`opening`]). + pub opening: Option, /// A brief notice over the bottom of the window (missing fonts on opening a file). pub toast: Option, /// Documents (by uid) whose missing fonts have been mentioned. @@ -197,6 +462,8 @@ pub struct SlideApp { pub synthetic: Vec, /// Canvas screen rect and points-per-slide-point scale (for control & tests). pub canvas_rect: Option, + /// The Eyedropper is waiting for a click on the slide. + pub eyedropper: Option, pub canvas_scale: f32, pub slide_rect: Option, /// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen. @@ -209,6 +476,8 @@ pub struct SlideApp { /// creating or closing a presentation leaves it. pub backstage: Option, pending_urls: Vec, + /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame. + pending_copy: Option, shot_token: u64, queued_shots: Vec<(u64, f64, u32)>, pending_shots: Vec<(u64, Option, Sender, f64)>, @@ -220,33 +489,68 @@ pub struct SlideApp { pub last_recovery: f64, /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, - pub(crate) notes_buf: (u64, usize, String), + pub(crate) notes_buf: (u64, usize, notes::Draft), /// Faces borrowed from the font database for text the interface fonts can't draw (#101). pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// View › New Window: a second window on one presentation. `None` when it is closed. + pub doc_window: Option, + /// Slide Sorter: folded sections, inline rename, dragging a whole section. + pub sorter: sorter::State, + /// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes. + pub(crate) slides_focused: bool, + /// A V key press or a Paste event arrived since the last V release (see `keyboard`). + v_seen: bool, + /// Which recent paths exist on disk, probed once per path per session (a dead network share + /// must not stall every frame). Not persisted. + recent_checked: HashMap, + /// A trackpad twist is in progress (one undo step when it ends). + twisting: bool, + /// Next cancellation identity for a browser pick or a dropped file. + next_file_id: u64, + /// Picks and drops whose bytes have not arrived yet. + pending_files: Vec, +} + +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } } impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); SlideApp { + table_pen: serde_json::json!({"width": 1.0}), session, services, ui: UiState::default(), textures: textures::Textures::default(), control_rx: None, + squiggles: Default::default(), dialog: None, + thesaurus_query: String::new(), show: None, + anim_preview: None, media: media::MediaHost::new(audio_out), status: None, + opening: None, toast: None, fonts_noted: Default::default(), perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, synthetic: vec![], canvas_rect: None, + eyedropper: None, canvas_scale: 1.0, slide_rect: None, canvas_region: None, @@ -254,6 +558,7 @@ impl SlideApp { integrated_titlebar: false, backstage: None, pending_urls: vec![], + pending_copy: None, shot_token: 0, queued_shots: vec![], pending_shots: vec![], @@ -263,10 +568,18 @@ impl SlideApp { last_time: 0.0, last_recovery: 0.0, quit_confirmed: false, - notes_buf: (0, usize::MAX, String::new()), + notes_buf: (0, usize::MAX, notes::Draft::default()), ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + doc_window: None, + sorter: sorter::State::default(), + slides_focused: false, + v_seen: false, + recent_checked: HashMap::new(), + twisting: false, + next_file_id: 1, + pending_files: Vec::new(), } } @@ -279,6 +592,62 @@ impl SlideApp { self.status = Some((msg.into(), now_ms())); } + /// A message that must be seen wherever the user is: a toast, which also shows over the start + /// screen and the File tab, where there is no status bar. + pub fn notify(&mut self, msg: impl Into) { + self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() }); + } + + /// The transient status message while it is fresh; clears it once stale. Both the status bar + /// and the start screen draw it — the start screen has no status bar, and errors raised there + /// (a missing recent file, a failed sample) used to vanish (issue #172). + pub fn fresh_status(&mut self) -> Option { + let (msg, at) = self.status.clone()?; + if now_ms() - at < STATUS_LINGER_MS { + Some(msg) + } else { + self.status = None; + None + } + } + + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned. pub fn run(&mut self, id: &str, params: Value) -> Result { @@ -287,8 +656,16 @@ impl SlideApp { } else { self.session.execute(id, ¶ms).map_err(|e| e.to_string()) }; - if let Err(e) = &r { - self.set_status(e.clone()); + match &r { + // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel + // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). + Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { + if let Some(t) = v.get("text").and_then(Value::as_str) { + self.pending_copy = Some(t.to_string()); + } + } + Ok(_) => {} + Err(e) => self.set_status(e.clone()), } self.drain_requests(); r @@ -365,6 +742,7 @@ impl SlideApp { "pptx" => ("pptx", "pptx", false), "outline" | "txt" => ("txt", "outline", false), "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), other => { self.set_status(format!("DeckCraft can't export “{other}”")); return false; @@ -388,10 +766,16 @@ impl SlideApp { let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; let bytes = match format { "png" | "jpeg" => { - let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); - Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) }) + // Every slide for "all slides", one download each, named like the desktop export (#64). + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let mut any = false; + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) { + any = self.download(&file, Ok(bytes)) || any; + } + return any; } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), }; self.download(&suggested, bytes) @@ -457,7 +841,60 @@ impl SlideApp { "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; @@ -472,7 +909,21 @@ impl SlideApp { let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); } + "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } "view.pane" => { let pane = p.get("pane").and_then(Value::as_str).map(String::from); self.ui.pane = @@ -493,15 +944,29 @@ impl SlideApp { self.restyle = true; } "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), + "view.newWindow" => return doc_window::command(self, p), "show.start" => { - let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); - self.start_show(from, false); + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + self.start_custom_show(name, false); + } else if p.get("from").is_some() { + let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); + self.start_show(from, false); + } else { + self.start_show(self.show_start_index(), false); + } } "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), "show.end" => { self.show = None; self.media.stop_owned(media::Owner::Show); } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + return self.start_anim_preview(); + } "app.home" => { let on = self.flag(p, self.backstage_open()); if on { @@ -537,10 +1002,48 @@ impl SlideApp { "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), + "app.browseThemeDialog" => { + if self.session.active().is_none() { + return Err("open a presentation first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("theme", "design.browseTheme"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Browse for Themes needs the desktop app for now".to_string()); + } + } + "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -554,48 +1057,200 @@ impl SlideApp { Ok(Value::Null) } + /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the + /// thumbnail pane has been clicked. + pub(crate) fn slides_list_focused(&self) -> bool { + self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) + } + + /// First slide Play from Start should open: the custom show or range, skipping hidden slides. + pub fn show_start_index(&self) -> usize { + self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) + } + pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); if let Some(d) = self.session.active() { self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading)); } } + /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. + pub fn start_custom_show(&mut self, name: &str, reading: bool) { + let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); + let Some(state) = state else { return }; + let from = state.slide; + self.start_show(from, reading); + if let Some(show) = self.show.as_mut() { + show.state = state; + } + } + + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + fn start_anim_preview(&mut self) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, elapsed: 0.0, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration})) + } + fn pick_and_open(&mut self) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick("open") { - let _ = self.open_path(&path); + self.open_path_in_background(&path); + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); } - } else if let Some(f) = self.services.open_async.as_mut() { - f("open"); } } pub fn open_path(&mut self, path: &str) -> Result<(), String> { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(12); - self.ui.view = ViewMode::Normal; + self.note_recent(path); Ok(()) } + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. + pub(crate) fn note_recent(&mut self, path: &str) { + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(RECENT_MAX); + self.ui.view = ViewMode::Normal; + } + + /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. + pub(crate) fn pick_transition_sound(&mut self) { + if self.services.pick_open.is_some() { + self.pick_and_insert("audio", "transition.sound"); + } + } + + /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the + /// current one (desktop only: the browser picker opens what it picks as a document). + fn pick_and_reuse(&mut self, keep: bool) { + let Some(pick) = self.services.pick_open.as_mut() else { + self.set_status("Reuse Slides needs the desktop app".to_string()); + return; + }; + let Some(path) = pick("open") else { return }; + match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) { + Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])), + Err(e) => self.set_status(e.to_string()), + } + } + fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; let r = match self.services.read.as_ref().map(|r| r(&path)) { Some(Ok(bytes)) => { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - self.session.execute(cmd, &json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&bytes)})) + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) } - _ => self.session.execute(cmd, &json!({"path": path})), }; if let Err(e) = r { self.set_status(e.to_string()); } } - } else if let Some(f) = self.services.open_async.as_mut() { - f(purpose); + } else if self.services.open_async.is_some() { + let id = self.begin_external_file(purpose); + // Change Picture: the picture that arrives replaces the selected one (#115). + if cmd == "picture.change" + && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) + { + r.purpose = FilePurpose::ChangePicture; + } + if let Some(f) = self.services.open_async.as_mut() { + f(purpose, id); + } } } @@ -668,8 +1323,8 @@ impl SlideApp { } UiRequest::Media { action, shape, ms } => { let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - if let Some(doc) = self.session.active().map(|d| d.doc.clone()) { - self.media.request(&doc, &action, shape, ms, owner); + if let Some((uid, doc)) = self.session.active().map(|d| (d.uid, d.doc.clone())) { + self.media.request(uid, &doc, &action, shape, ms, owner); } } } @@ -678,42 +1333,182 @@ impl SlideApp { fn drain_inbox(&mut self) { let Some(inbox) = self.services.inbox.clone() else { return }; - let files: Vec<(String, Vec)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default(); - for (name, bytes) in files { - self.open_or_insert_bytes(&name, &bytes); + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } } } - /// A dropped or picked file: presentations open, media is inserted. + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let lower = name.to_ascii_lowercase(); - let data = deckcraft_engine::cmd::base64_encode(bytes); - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - let cmd = if lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") - { - "file.openBytes" - } else if ct.starts_with("image/") { - "insert.picture" - } else if ct.starts_with("audio/") { - "insert.audio" - } else if ct.starts_with("video/") { - "insert.video" - } else if lower.ends_with(".txt") || lower.ends_with(".md") { - "slide.fromOutline" - } else { - self.set_status(format!("{name}: DeckCraft can't open this kind of file")); - return; + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), }; - let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) }; - if self.session.active().is_none() && cmd != "file.openBytes" { - let _ = self.session.execute("file.new", &json!({})); + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; } - if let Err(e) = self.session.execute(cmd, &p) { - self.set_status(e.to_string()); + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::NotesPage(t) => d.selection.target == *t, + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + match result { + Err(e) if cmd == "file.openBytes" => self.notify(e), + Err(e) => self.set_status(e), + Ok(()) => {} + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), + FilePlace::Unspecified => Err("there was no slide to put it on".into()), } } @@ -741,6 +1536,7 @@ impl SlideApp { } self.last_time = now; self.drain_control(ctx); + self.poll_opening(); if self.fonts_ready { self.drain_inbox(); } @@ -770,7 +1566,7 @@ impl SlideApp { } } // On the web, dropped files can only be read asynchronously: the host reads them and - // delivers them through `Services::inbox`. + // delivers them through `Services::inbox`, tagged with the request captured at the drop. #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); @@ -779,6 +1575,7 @@ impl SlideApp { Err(e) => self.set_status(e), } } + canvas::trackpad_twist(self, ctx); } /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open @@ -845,10 +1642,25 @@ impl SlideApp { /// Keyboard and clipboard for the slide (when no text field has focus). fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + for e in &events { + match e { + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { + // Only a release with Command/Ctrl held is a paste: a bare V release is not. + let seen = std::mem::take(&mut self.v_seen); + image_paste = !seen && (modifiers.command || modifiers.ctrl); + } + _ => {} + } + } if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() { return; } - let events = ctx.input(|i| i.events.clone()); if self.backstage.is_some() { // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the @@ -871,6 +1683,13 @@ impl SlideApp { self.drain_requests(); return; } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } for e in events { match e { egui::Event::Text(t) => { @@ -881,34 +1700,42 @@ impl SlideApp { } } egui::Event::Ime(ime) => self.ime_event(&ime), - egui::Event::Copy => { - if let Ok(v) = self.session.execute("edit.copy", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } - egui::Event::Cut => { - if let Ok(v) = self.session.execute("edit.cut", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => { - // An image on the clipboard (screenshots, copied pictures) becomes a picture. + // Our own marker for objects without text: paste what the session copied. + let text = if text == OBJECTS_MARKER { String::new() } else { text }; + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { Some(png) => self .session .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => self.session.execute("edit.paste", &json!({"text": text})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } }; if let Err(e) = r { self.set_status(e.to_string()); } } egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; @@ -925,6 +1752,24 @@ impl SlideApp { self.drain_requests(); } + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). + fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { + return; + } + ctx.copy_text(system_clipboard_text(&text)); + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -935,8 +1780,12 @@ impl SlideApp { let t0 = now_ms(); let t = theme::Tokens::get(&ctx); self.tick_media(&ctx); + eyedropper::tick(self, &ctx); + self.opening_ui(&ctx); if self.show.is_some() { + self.anim_preview = None; show::ui(self, ui); + doc_window::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; return; } @@ -948,6 +1797,10 @@ impl SlideApp { ribbon::show(self, ui); status::status_bar(self, ui); } + let now = ui.input(|i| i.time); + if self.tick_anim_preview(now) { + ctx.request_repaint(); + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { if self.backstage.is_some() { // File tab: the Backstage view replaces everything below the title bar. @@ -985,12 +1838,19 @@ impl SlideApp { for url in std::mem::take(&mut self.pending_urls) { ctx.open_url(egui::OpenUrl::new_tab(url)); } + if let Some(t) = self.pending_copy.take() { + ctx.copy_text(t); + } self.drain_requests(); + doc_window::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; } - /// Media clocks and video textures; the editor's media stops when its slide is left. + /// Media clocks and video textures. Voices belong to the open presentation: closing the last + /// one, or switching decks, stops them. The editor's media also stops when its slide is left. fn tick_media(&mut self, ctx: &egui::Context) { + let uid = self.session.active().map(|d| d.uid); + self.media.set_document(uid); if !self.media.any_active() { self.session.media_status.clear(); return; @@ -1122,19 +1982,125 @@ pub fn now_ms() -> f64 { } } +/// Images for the browser download: the current slide, or every slide (named `1.png`, +/// `2.png`... like the native export) when `all`. +fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + let scale = 2.0; + let dpi = deckcraft_render::dpi_for_scale(scale); + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} + #[cfg(target_arch = "wasm32")] fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod animpane_tests; +#[cfg(test)] +mod browsetheme_tests; +#[cfg(test)] +mod compare_tests; +#[cfg(test)] +mod eyedropper_tests; +#[cfg(test)] +mod huge_deck_tests; #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod replacefonts_tests; +#[cfg(test)] +mod slide_size_tests; +#[cfg(test)] +mod texture_limit_tests; +#[cfg(test)] +mod ui_soak_tests; + +#[cfg(test)] +#[path = "datetime_tests.rs"] +mod datetime_tests; + +#[cfg(test)] +mod inbox_tests; #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use egui_kittest::kittest::Queryable; + + fn shape_rot(app: &SlideApp) -> f64 { + let d = app.session.active().unwrap(); + let id = d.selection.shapes[0]; + d.shape(id).unwrap().xfrm.unwrap().rot + } + + /// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; + /// letting go is one undo step. Off the canvas it does nothing. + #[test] + fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); + let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.run_steps(1); + let canvas = harness.state().canvas_rect.expect("canvas"); + let outside = egui::pos2(1.0, 1.0); + assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); + + harness.input_mut().events.push(egui::Event::PointerMoved(outside)); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); + + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); + harness.run_steps(1); + assert!(harness.state().session.active().unwrap().interaction.is_none()); + harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); + } + + /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). + #[test] + fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png"); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png"); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); + } fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); @@ -1142,6 +2108,40 @@ mod tests { sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } + #[test] + fn quick_access_toolbar_adds_removes_resets_and_persists() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); + assert_eq!(ids(harness.state()), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + let app = harness.state_mut(); + app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); + app.run("app.qat", json!({"add": "insert.table"})).unwrap(); + app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); + assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); + app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); + assert_eq!(app.ui.qat[5].label, "Table"); + // Saved with the rest of the UI state and read back. + let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.qat, app.ui.qat); + harness.run_steps(2); + let app = harness.state_mut(); + app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); + assert_eq!(app.ui.qat.len(), 6); + app.run("app.qat", json!({"reset": true})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + // An older settings file without the toolbar gets the defaults. + let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); + assert_eq!(old.qat.len(), 6); + } + #[test] fn opening_a_file_with_missing_fonts_shows_a_toast_once() { use egui_kittest::kittest::Queryable; @@ -1173,6 +2173,61 @@ mod tests { assert!(harness.state().toast.is_none()); } + /// #115: Change Picture replaced nothing — it inserted a second picture. + #[test] + fn change_picture_replaces_the_selected_picture() { + let svg = |c: &str| { + format!("").into_bytes() + }; + let media = |app: &SlideApp| { + let d = app.session.active().unwrap(); + let pics: Vec<_> = d.doc.slides[0] + .shapes + .iter() + .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) + .collect(); + assert_eq!(pics.len(), 1, "one picture, replaced in place"); + pics[0] + }; + let insert = |app: &mut SlideApp| { + let id = + app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); + app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + media(app) + }; + + // Desktop: the file dialog returns a path. + let blue = svg("blue"); + let services = Services { + pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), + read: Some(Box::new(move |_: &str| Ok(blue.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + assert_ne!(media(&app), before); + + // Web: the browser picker delivers the file through the inbox later. + let inbox = Inbox::default(); + let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), + inbox: Some(inbox.clone()), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + let id = started.lock().unwrap()[0]; + inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); + app.drain_inbox(); + assert_ne!(media(&app), before); + } + /// #17: Home created a new blank presentation instead of going to the start screen. #[test] fn home_shows_the_start_screen_over_the_open_presentation() { @@ -1299,12 +2354,225 @@ mod tests { assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); } + /// Export… had no visible control: only the command palette reached it. + #[test] + fn title_bar_export_opens_the_export_dialog() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + // Quick access: Home, Save, Export, each 24 points wide with 2 between, from x = 10. + let pos = egui::pos2(10.0 + 2.0 * 26.0 + 12.0, ribbon::TITLE_H / 2.0); + harness.hover_at(pos); + harness.run_steps(1); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); + } + + /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and + /// swallows the key press, so only the V release reaches the app. + #[test] + fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + assert!(!png.is_empty()); + let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let pictures = |app: &SlideApp| { + let d = app.session.active().unwrap(); + d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() + }; + let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // A text paste (Paste event on the press) does not also paste the image on release. + harness.event(egui::Event::Paste("hello".into())); + harness.run_steps(2); + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // A V release without Command/Ctrl is not a paste. + harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 1); + } + + /// #78: after Tab in a text box, typing and deleting no longer reached the text. + #[test] + fn typing_goes_on_after_tab_in_a_text_box() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + { + let mut session = Session::with_new(); + session.prefs.autocorrect = false; + SlideApp::new(session, Services::default()) + }, + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("ab".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("c".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\tc"); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t"); + assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); + // Tab at the start of a paragraph indents it, and typing goes on there too (#93). + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("d".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t\nd"); + let level = |h: &egui_kittest::Harness| { + h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level + }; + assert_eq!(level(&harness), 1); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); app.run("app.home", json!({})).unwrap(); assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); } + + /// View › New Window is a second window on the open presentation. Its slide is its own; + /// the editor's selection stays where it was. Closing the window leaves the document open. + #[test] + fn new_window_shows_the_same_document_on_its_own_slide() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.go", &json!({"index": 1})).unwrap(); + let err = { + let mut empty = SlideApp::new(Session::new(), Services::default()); + empty.run("view.newWindow", json!({})).unwrap_err() + }; + assert!(err.contains("presentation"), "{err}"); + + let name = app.session.active().unwrap().title(); + let opened = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(opened["open"], true); + assert_eq!(opened["window"], 2); + assert_eq!(opened["slide"], 1); + assert_eq!(opened["title"], format!("{name}:2")); + assert_eq!(app.session.active().unwrap().selection.slide, 1); + + let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap(); + assert_eq!(moved["slide"], 0); + assert_eq!(app.doc_window.as_ref().unwrap().slide, 0); + assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide"); + + let again = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(again["window"], 2); + assert_eq!(again["slide"], 0, "opening again keeps the window's slide"); + + app.session.execute("file.close", &json!({})).unwrap(); + doc_window::show(&mut app, &egui::Context::default()); + assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); + assert!(app.session.active().is_none()); + } + + #[test] + fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + } + + #[test] + fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { @@ -1408,4 +2676,581 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + /// #431: the ribbon's Copy and Cut, the slide's context menu, the menus and the control + /// channel all run the commands; they put the text on the system clipboard, as Ctrl+C and + /// Ctrl+X do. + #[test] + fn copy_and_cut_commands_reach_the_system_clipboard() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let copied = |h: &egui_kittest::Harness<'_, SlideApp>| { + h.output().platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + }; + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.type_text("Aurora").unwrap(); + harness.state_mut().session.key("Escape", Mods::default()).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness), None); + + harness.state_mut().run("edit.copy", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Copy puts the text on the clipboard"); + harness.run_steps(1); + assert_eq!(copied(&harness), None, "only once"); + + harness.state_mut().run("edit.cut", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); + assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); + } + + /// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because + /// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. + #[test] + fn copy_then_paste_shortcut_duplicates_an_object_without_text() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); + let before = count(&harness); + + // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. + assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); + assert_eq!(system_clipboard_text("hello"), "hello"); + harness.event(egui::Event::Copy); + harness.run_steps(2); + harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); + harness.run_steps(2); + assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); + let d = harness.state().session.active().unwrap(); + let pasted = d.doc.slides[0].shapes.last().unwrap(); + assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); + } + + /// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the + /// canvas focused it still selects shapes. + #[test] + fn select_all_follows_the_focused_pane() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + for _ in 0..2 { + harness.state_mut().run("slide.new", json!({})).unwrap(); + } + harness.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + harness.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SlideApp>, pos: egui::Pos2| { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); + }; + // Press in the thumbnail pane, then Cmd+A. + let w = harness.state().ui.thumbs_width; + click(&mut harness, egui::pos2(w / 2.0, 300.0)); + assert!(harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert_eq!(st.selection.slides.len(), 3, "every slide is selected"); + + // A press on the canvas moves focus away: Cmd+A selects shapes again. + click(&mut harness, egui::pos2(700.0, 400.0)); + assert!(!harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); + } + + fn harness() -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::new(), Services::default()), + ) + } + + /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws + /// the open error right there (the status bar is hidden while the backstage is open, so the + /// message used to vanish). + #[test] + fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + // The file is read in the background (#350): wait for that read to finish. + for _ in 0..500 { + if harness.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + harness.run_steps(1); + } + harness.run_steps(2); + assert!(harness.state().toast.is_some(), "the open error was raised"); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); + } + + /// #172: a Recent entry can be removed from the list. + #[test] + fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + assert_eq!(removes.len(), 2); + // The remove button must sit inside the visible part of the row even when the page's + // content is wider than the window. + assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); + removes[1].click(); + harness.run_steps(1); + assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); + } + + /// The existence probe tells present files from missing ones and never touches remote paths. + #[test] + fn recent_exists_tells_missing_from_present() { + let mut app = SlideApp::new(Session::new(), Services::default()); + let mut path = std::env::temp_dir(); + path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); + assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); + assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); + std::fs::remove_file(&path).ok(); + } + + /// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. + #[test] + fn the_language_dialog_marks_the_selected_text() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); + harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + // The anchored window settles over its first frames; click once it stays put. + harness.run_steps(4); + harness.get_by_label("Français (France)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); + } + + /// Speaker notes keep their formatting when edited in the notes pane: typing into a bold + /// heading, Enter in a bulleted point, then saving as .pptx and reopening. + #[test] + fn editing_notes_in_the_pane_keeps_their_formatting() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |runs: Vec| Paragraph { + level: 1, + props: ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(Bullet::Char { char: "§".into() }), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }, + runs, + ..Default::default() + }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), + point(vec![Run::new("Reflexia")]), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + }; + let mut session = Session::with_new(); + session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let id = egui::Id::new("notes_edit"); + let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { + let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); + egui::TextEdit::store_state(&h.ctx, id, st); + }; + let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); + let text = notes.text(); + harness.ctx.memory_mut(|m| m.request_focus(id)); + harness.run_steps(1); + // Type into the bold heading. + caret(&mut harness, chars(&text, "prehľad")); + harness.run_steps(1); + harness.event(egui::Event::Text(" hodiny".into())); + harness.run_steps(1); + // Enter after a bulleted point, and type the next one. + let text = text.replacen("prehľad", "prehľad hodiny", 1); + caret(&mut harness, chars(&text, "cyklus")); + harness.run_steps(1); + harness.key_press(egui::Key::Enter); + harness.event(egui::Event::Text("Nový bod".into())); + harness.run_steps(1); + // Leave the field: the notes are saved. + harness.ctx.memory_mut(|m| m.surrender_focus(id)); + harness.run_steps(2); + let check = |n: &TextBody| { + assert_eq!( + n.text(), + "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" + ); + assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); + assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(n.paragraphs[1].props, ParaProps::default()); + assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); + assert_eq!(n.paragraphs[2].runs[1].props.bold, None); + for i in [2, 3, 4] { + assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); + } + assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); + }; + check(&harness.state().session.active().unwrap().doc.slides[0].notes); + // Saved as .pptx and reopened. + let s = &mut harness.state_mut().session; + let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); + check(&s.active().unwrap().doc.slides[0].notes); + } + + fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), services), + ); + h.run_steps(2); + h + } + + /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. + #[test] + fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); + } + + /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can + /// still paste it here (#182). + #[test] + fn a_textless_shape_copies_as_the_objects_marker() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); + } + + fn redder(px: [u8; 4]) -> i32 { + px[0] as i32 - px[1].max(px[2]) as i32 + } + + /// A fade entrance is hidden at the start of the preview clock and opaque once it has played. + #[test] + fn preview_hides_an_entrance_until_it_plays() { + let mut session = Session::with_new(); + let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let shape = deckcraft_model::ShapeId(text as u32); + let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); + assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); + let mid = para(0.25, 0); + assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); + assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); + + session.execute("animation.clear", &json!({})).unwrap(); + let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); + let paint = |time: Option| { + let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); + let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; + let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; + deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) + }; + let rest = paint(None); + let start = paint(Some(0.0)); + let end = paint(Some(tl.total_duration() + 0.05)); + assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); + assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); + assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); + } + + #[test] + fn animation_preview_plays_on_the_canvas_then_stops() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let none = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(none["playing"], false); + assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); + assert!(harness.state().show.is_none()); + + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); + let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(started["playing"], true); + assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); + harness.run_steps(1); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); + let renders = harness.state().textures.canvas_renders; + harness.run_steps(6); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); + assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); + assert!(harness.state().show.is_none()); + + harness.key_press(egui::Key::Escape); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); + assert!(harness.state().anim_preview.is_none()); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + let centre = harness.state().slide_rect.unwrap().center(); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(2); + assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); + + harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + // 400 ms effect plus the hold. + harness.run_steps(30); + assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); + assert!(harness.state().show.is_none()); + } + + fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness + } + + fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide + } + + /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a + /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or + /// none of them reaches the page threshold. + fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); + } + + /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. + /// The frames of one gesture do not skip ahead. + #[test] + fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); + } + + #[test] + fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); + } + + #[test] + fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } + + /// The pen is drawing. A flick must not turn the page out from under the stroke. + #[test] + fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } } diff --git a/crates/ui-egui/src/master_layout_tests.rs b/crates/ui-egui/src/master_layout_tests.rs new file mode 100644 index 00000000..d4f5efa0 --- /dev/null +++ b/crates/ui-egui/src/master_layout_tests.rs @@ -0,0 +1,61 @@ +//! Slide Master ribbon: Preserve, Title and Footers on the open layout. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use deckcraft_model::PhType; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn app_on_blank() -> (SlideApp, usize) { + let mut session = Session::with_new(); + let blank = session.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); + session.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); + let mut app = SlideApp::new(session, Services::default()); + app.ui.tab = "Slide Master".into(); + (app, blank) +} + +fn click_check(h: &mut Harness<'_, SlideApp>, label: &str) { + h.query_all_by_role_and_label(Role::CheckBox, label).next().unwrap_or_else(|| panic!("no {label} checkbox")).click(); + h.run_steps(3); +} + +#[test] +fn slide_master_preserve_title_and_footers() { + let (app, blank) = app_on_blank(); + let mut h = harness(app); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Preserve").next().is_some()); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Title").next().is_some()); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Footers").next().is_some()); + { + let st = h.state().session.doc().unwrap(); + assert!(st.doc.masters[0].layouts[blank].preserve); + assert!(!st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))); + assert!(st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))); + } + + click_check(&mut h, "Title"); + assert!(h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))); + + click_check(&mut h, "Footers"); + assert!(!h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))); + + click_check(&mut h, "Preserve"); + assert!(!h.state().session.doc().unwrap().doc.masters[0].layouts[blank].preserve); +} diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index f887c2b2..7fd8fda7 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -25,13 +25,31 @@ struct Video { tex: Option, } +/// A voice started for one shape in one presentation. +struct Owned { + owner: Owner, + clip: MediaClip, + /// Address of the source bytes this voice was started with. + source: usize, +} + +/// A probed duration. `source` keeps that allocation alive so a later buffer cannot reuse the +/// address and inherit this length. +struct Probed { + source: Arc>, + duration: f64, +} + pub struct MediaHost { player: Player, - owners: HashMap, + /// Presentation the voices and GIF clocks belong to. `None` before the first document is + /// bound, and after the last one closes. + doc: Option, + owners: HashMap, videos: HashMap, - /// Probed durations by media bytes (for media imported without a probe). - durations: HashMap, - /// Animated GIF clocks: when each started and, while paused, the time it holds. + /// Probed durations keyed by the source allocation (media imported without a length). + durations: HashMap, + /// Animated GIF clocks for `doc`: when each started and, while paused, the time it holds. gifs: HashMap)>, } @@ -57,12 +75,68 @@ fn key(id: ShapeId) -> u64 { impl MediaHost { pub fn new(out: Option>) -> MediaHost { - MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() } + MediaHost { + player: Player::new(out), + doc: None, + owners: HashMap::new(), + videos: HashMap::new(), + durations: HashMap::new(), + gifs: HashMap::new(), + } + } + + /// Bind playback to the open presentation. `None` (nothing open) stops every voice. Switching + /// to a different document stops the previous one's voices and GIF clocks, so a shape id that + /// exists in both decks cannot keep the old clip. + pub fn set_document(&mut self, uid: Option) { + if self.doc == uid { + return; + } + if uid.is_none() || self.doc.is_some() { + self.stop_voices(); + self.gifs.clear(); + } + self.doc = uid; + } + + fn ensure_doc(&mut self, uid: u64) { + self.set_document(Some(uid)); + } + + fn stop_voices(&mut self) { + self.player.stop_all(); + self.owners.clear(); + self.videos.clear(); + } + + /// Length of `bytes` in seconds, remembered for this allocation. The map keeps the bytes, and + /// stays within 16 entries. + fn duration_of(&mut self, bytes: &Arc>) -> f64 { + let media_key = Arc::as_ptr(bytes) as usize; + if let Some(hit) = self.durations.get(&media_key) { + return hit.duration; + } + if self.durations.len() >= 16 { + let drop_key = self + .durations + .iter() + .find(|(_, p)| Arc::strong_count(&p.source) <= 1) + .map(|(k, _)| *k) + .or_else(|| self.durations.keys().next().copied()); + if let Some(k) = drop_key { + self.durations.remove(&k); + } + } + let duration = deckcraft_media::probe(bytes).map(|i| i.duration()).unwrap_or(0.0); + self.durations.insert(media_key, Probed { source: Arc::clone(bytes), duration }); + duration } /// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was - /// first shown; a paused one holds its time and resumes from it. - pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet) -> Vec { + /// first shown; a paused one holds its time and resumes from it. Clocks belong to `doc_uid` + /// and are dropped when the open presentation changes. + pub fn gifs(&mut self, doc_uid: u64, doc: &Presentation, index: usize, now: f64, paused: &HashSet) -> Vec { + self.ensure_doc(doc_uid); let Some(slide) = doc.slides.get(index) else { return vec![] }; let mut out = vec![]; for sh in &slide.shapes { @@ -85,9 +159,7 @@ impl MediaHost { /// Replace the audio output (the host injects cpal after start-up). pub fn set_output(&mut self, out: Option>) { - self.player.stop_all(); - self.owners.clear(); - self.videos.clear(); + self.stop_voices(); self.player = Player::new(out); } @@ -101,7 +173,7 @@ impl MediaHost { } pub fn owner(&self, id: ShapeId) -> Option { - self.owners.get(&id).map(|o| o.0) + self.owners.get(&id).map(|o| o.owner) } /// Shapes with a voice (playing, paused or ended). @@ -113,23 +185,21 @@ impl MediaHost { !self.owners.is_empty() } - /// Start (or resume) shape `id` from `from` seconds. - pub fn play(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, from: Option, owner: Owner) { + /// Start (or resume) shape `id` of presentation `doc_uid` from `from` seconds. + pub fn play(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, from: Option, owner: Owner) { + self.ensure_doc(doc_uid); let Some(item) = doc.media(clip.media) else { return }; let bytes = item.data.clone(); let media_key = Arc::as_ptr(&bytes) as usize; - let duration = if clip.duration_ms > 0 { - clip.duration_ms as f64 / 1000.0 - } else { - *self.durations.entry(media_key).or_insert_with(|| deckcraft_media::probe(&bytes).map(|i| i.duration()).unwrap_or(0.0)) - }; + let duration = if clip.duration_ms > 0 { clip.duration_ms as f64 / 1000.0 } else { self.duration_of(&bytes) }; let spec = VoiceSpec { clip: ClipParams::from_ms(clip.trim_start_ms, clip.trim_end_ms, clip.fade_in_ms, clip.fade_out_ms, clip.volume), looping: clip.loop_play, duration, audio: clip.volume > 0.0, }; - if clip.video && !self.videos.contains_key(&id) { + let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key); + if clip.video && (source_changed || !self.videos.contains_key(&id)) { let feed = match VideoFeed::new(bytes.clone()) { Ok(f) => Some(f), Err(e) => { @@ -138,20 +208,27 @@ impl MediaHost { } }; self.videos.insert(id, Video { feed, frame: None, tex: None }); + } else if !clip.video { + self.videos.remove(&id); } self.player.play(key(id), media_key as u64, &bytes, spec, from); - self.owners.insert(id, (owner, clip.clone())); + self.owners.insert(id, Owned { owner, clip: clip.clone(), source: media_key }); } pub fn pause(&mut self, id: ShapeId) { self.player.pause(key(id)); } - pub fn toggle(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, owner: Owner) { - if self.is_playing(id) { + fn same_source(&self, doc: &Presentation, id: ShapeId, clip: &MediaClip) -> bool { + doc.media(clip.media).is_some_and(|item| self.owners.get(&id).is_some_and(|o| o.source == Arc::as_ptr(&item.data) as usize)) + } + + pub fn toggle(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, owner: Owner) { + self.ensure_doc(doc_uid); + if self.is_playing(id) && self.same_source(doc, id, clip) { self.pause(id); } else { - self.play(doc, id, clip, None, owner); + self.play(doc_uid, doc, id, clip, None, owner); } } @@ -162,16 +239,17 @@ impl MediaHost { } pub fn stop_owned(&mut self, owner: Owner) { - let ids: Vec = self.owners.iter().filter(|(_, o)| o.0 == owner).map(|(id, _)| *id).collect(); + let ids: Vec = self.owners.iter().filter(|(_, o)| o.owner == owner).map(|(id, _)| *id).collect(); for id in ids { self.stop(id); } } - /// Move to `t` seconds; a shape that isn't playing is cued there, paused. - pub fn seek(&mut self, doc: &Presentation, id: ShapeId, clip: &MediaClip, t: f64, owner: Owner) { - if self.status(id).is_none() { - self.play(doc, id, clip, Some(t), owner); + /// Move to `t` seconds; a shape that isn't playing, or whose clip changed, is cued there, paused. + pub fn seek(&mut self, doc_uid: u64, doc: &Presentation, id: ShapeId, clip: &MediaClip, t: f64, owner: Owner) { + self.ensure_doc(doc_uid); + if self.status(id).is_none() || !self.same_source(doc, id, clip) { + self.play(doc_uid, doc, id, clip, Some(t), owner); self.pause(id); } self.player.seek(key(id), t); @@ -188,10 +266,10 @@ impl MediaHost { let now = ctx.input(|i| i.time); self.player.tick(now); let mut ended = vec![]; - for (id, (_, clip)) in &self.owners { + for (id, owned) in &self.owners { if let Some(st) = self.player.status(key(*id)) && st.state == PlayState::Ended - && clip.rewind + && owned.clip.rewind { ended.push(*id); } @@ -209,8 +287,8 @@ impl MediaHost { { let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); match v.tex.as_mut() { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)), + Some(t) => crate::textures::replace(ctx, t, img), + None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), } v.frame = Some(f); } @@ -239,16 +317,17 @@ impl MediaHost { playing } - /// Carry out a `media.*` request from the engine. - pub fn request(&mut self, doc: &Presentation, action: &str, id: ShapeId, ms: Option, owner: Owner) { + /// Carry out a `media.*` request from the engine for presentation `doc_uid`. + pub fn request(&mut self, doc_uid: u64, doc: &Presentation, action: &str, id: ShapeId, ms: Option, owner: Owner) { + self.ensure_doc(doc_uid); let Some(clip) = find_clip(doc, id) else { return }; let t = ms.map(|v| v as f64 / 1000.0); match action { - "play" => self.play(doc, id, &clip, t, owner), + "play" => self.play(doc_uid, doc, id, &clip, t, owner), "pause" => self.pause(id), - "toggle" => self.toggle(doc, id, &clip, owner), + "toggle" => self.toggle(doc_uid, doc, id, &clip, owner), "stop" => self.stop(id), - "seek" => self.seek(doc, id, &clip, t.unwrap_or(0.0), owner), + "seek" => self.seek(doc_uid, doc, id, &clip, t.unwrap_or(0.0), owner), _ => {} } } @@ -421,3 +500,121 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: } action } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + + use deckcraft_geom::Xfrm; + use deckcraft_model::style::PictureFill; + use deckcraft_model::{MediaClip, Presentation, Shape, ShapeId, ShapeKind}; + + use super::*; + + /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. + fn wav_level(secs: f64, amp: f32) -> Vec { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b + } + + fn deck(shape: u32, bytes: Vec) -> (Presentation, ShapeId, MediaClip) { + let mut doc = Presentation::default(); + let media = doc.add_media("clip.wav", "audio/wav", bytes).expect("media"); + let clip = MediaClip { media, volume: 1.0, ..Default::default() }; + let id = ShapeId(shape); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(Shape { id, xfrm: Some(Xfrm::new(0.0, 0.0, 120.0, 40.0)), kind: ShapeKind::Media(clip.clone()), ..Default::default() }); + (doc, id, clip) + } + + /// First mixed sample of shape `id`, after its audio has decoded. + fn level(host: &MediaHost, id: ShapeId) -> f32 { + assert!(host.player.wait_loaded(key(id), std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + host.player.render(&mut buf, 1, 8_000); + buf[0] + } + + #[test] + fn playback_follows_the_document_and_the_clip() { + let (loud_doc, id, loud) = deck(4000, wav_level(0.05, 0.5)); + let (quiet_doc, _, quiet) = deck(4000, wav_level(0.10, 0.25)); + let mut host = MediaHost::new(None); + + host.play(1, &loud_doc, id, &loud, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.5).abs() < 0.02, "loud sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + let loud_bytes = &loud_doc.media(loud.media).expect("media").data; + let ptr = Arc::as_ptr(loud_bytes) as usize; + assert!(host.durations.get(&ptr).is_some_and(|p| Arc::ptr_eq(&p.source, loud_bytes))); + + // Replacing the clip on the same shape plays the new bytes, not the voice already running. + host.play(1, &quiet_doc, id, &quiet, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.25).abs() < 0.02, "replaced sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "replaced duration {dur}"); + assert_eq!(host.player.keys(), vec![key(id)]); + + // A second deck reuses the shape id. Leaving the first deck stops its voice. + host.set_document(Some(2)); + assert!(host.status(id).is_none(), "the previous document's voice is gone"); + host.play(2, &loud_doc, id, &loud, None, Owner::Editor); + let sample = level(&host, id); + assert!((sample - 0.5).abs() < 0.02, "second document sample {sample}"); + let dur = host.status(id).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "second document duration {dur}"); + + host.set_document(None); + assert!(host.ids().is_empty(), "closing the last document stops playback"); + assert!(host.status(id).is_none()); + } + + #[test] + fn gif_clocks_do_not_carry_across_documents() { + let gif = |shape: u32| { + let mut doc = Presentation::default(); + let media = doc.add_media("a.gif", "image/gif", b"GIF89a-not-animated".to_vec()).expect("media"); + let id = ShapeId(shape); + let slide = Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(Shape { + id, + xfrm: Some(Xfrm::new(0.0, 0.0, 80.0, 80.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }); + (doc, id) + }; + let (a, id) = gif(7); + let (b, _) = gif(7); + let mut host = MediaHost::new(None); + let none = HashSet::new(); + host.gifs(1, &a, 0, 10.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(10.0)); + host.gifs(1, &a, 0, 12.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(10.0), "the same document keeps its clock"); + host.gifs(2, &b, 0, 50.0, &none); + assert_eq!(host.gifs.get(&id).map(|c| c.0), Some(50.0), "a new document starts its own clock"); + host.set_document(None); + assert!(host.gifs.is_empty()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..440fd939 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,9 +10,14 @@ use crate::theme::{self, Tokens}; /// Shortcuts the UI handles itself (views, show, zoom, palette, file dialogs). True when consumed. pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { use egui::Key::*; + if key == A && m.cmd && !m.shift && !m.alt && app.slides_list_focused() { + // The engine can't see which pane has focus, so the UI says so through the scope param. + let _ = app.run("edit.selectAll", json!({"scope": "slides"})); + return true; + } let id = match (key, m.cmd, m.shift, m.alt) { (F5, _, false, _) => { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); return true; } (F5, _, true, _) | (Enter, true, false, false) => { @@ -21,7 +26,7 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { return true; } (Enter, true, true, _) => { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); return true; } (S, true, false, false) => { @@ -49,6 +54,10 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { return true; } (O, true, false, false) => "app.openDialog", + (V, true, false, true) if app.session.active().is_some() => { + app.dialog = Some(crate::dialogs::Dialog::new("pasteSpecial")); + return true; + } (P, true, true, false) => "app.palette", (Comma, true, false, false) => "app.preferences", (Equals | Plus, true, false, false) => "view.zoomIn", @@ -61,16 +70,31 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); true } +/// The palette's entries for the lowercase query `q`. An engine command is hidden when a UI command has the same label: Open…, Save As…, +/// Export… and Audio/Video from File… need a `path` the palette can't give, so the one entry left opens the dialog instead of failing. +fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, String, Option<&'static str>)> { + let mut items: Vec<(String, String, Option<&'static str>)> = session + .commands() + .into_iter() + // Commands that need a path or other value fail when the palette runs them with `{}`. + .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label) && !deckcraft_engine::cmd::needs_arguments(c.params)) + .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) + .chain(crate::UI_COMMANDS.iter().filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)).map(|c| (c.0.to_string(), c.1.to_string(), c.2))) + .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(q) || id.to_lowercase().contains(q)) + .collect(); + items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q)); + items.truncate(60); + items +} + /// ⇧⌘P: search every command by name and run it. pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { let Some((mut query, mut sel)) = app.palette.take() else { return }; @@ -85,17 +109,7 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0))); r.request_focus(); let q = query.to_lowercase(); - let mut items: Vec<(String, String, Option<&'static str>)> = app - .session - .commands() - .into_iter() - .filter(|c| c.enabled) - .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) - .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) - .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(&q) || id.to_lowercase().contains(&q)) - .collect(); - items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(&q)); - items.truncate(60); + let items = palette_items(&app.session, &q); let (down, up, enter, esc) = ui.input(|i| { ( i.key_pressed(egui::Key::ArrowDown), @@ -171,12 +185,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("Paste and Match Formatting", "edit.pasteText"), + ("Paste Special…", "app.pasteSpecial"), ("-", ""), ("Select All", "edit.selectAll"), ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( @@ -190,14 +206,20 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Slide Show", "show.start"), ("-", ""), ("Slide Master", "view.slideMaster"), + ("Handout Master", "view.handoutMaster"), + ("Notes Master", "view.notesMaster"), ("-", ""), ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), ("Guides", "view.guides"), + ("Grid Options…", "view.gridOptions"), ("-", ""), + ("Zoom…", "view.zoomDialog"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut"), ("Fit to Window", "view.fit"), + ("-", ""), + ("New Window", "view.newWindow"), ], ), ( @@ -215,6 +237,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Table…", "insert.table"), ("Chart", "insert.chart"), ("Picture from File…", "app.insertPictureDialog"), + ("Photo Album…", "insert.photoAlbum"), + ("Edit Photo Album…", "insert.photoAlbumEdit"), ("Audio from File…", "app.insertAudioDialog"), ("Video from File…", "app.insertVideoDialog"), ], @@ -222,6 +246,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ( "Format", vec![ + ("Font…", "format.fontDialog"), + ("Paragraph…", "format.paragraphDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), @@ -231,6 +257,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Align Right", "format.alignRight"), ("Justify", "format.justify"), ("-", ""), + ("Size and Position…", "shape.sizePosition"), ("Right-to-Left Text Direction", "format.rtl"), ], ), @@ -265,3 +292,29 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Help", vec![("About DeckCraft", "app.about"), ("Command Palette", "app.palette")]), ] } + +#[cfg(test)] +mod tests { + use super::palette_items; + + /// #79: the palette listed two "Export…" entries and the first (`file.export`) failed with "missing `path`". + #[test] + fn palette_has_one_export_entry_that_opens_the_dialog() { + let session = deckcraft_engine::Session::with_new(); + for query in ["exp", "export"] { + let exports: Vec<_> = palette_items(&session, query).into_iter().filter(|(_, label, _)| label == "Export…").collect(); + assert_eq!(exports.len(), 1, "query {query}"); + assert_eq!(exports[0].0, "app.exportDialog"); + } + // Open…, Save As…, Audio/Video from File… and Notes had the same duplicates: every UI command is the only entry with its label. + // Commands that need a value (Set Zoom's percent) are left out of the palette. + for (id, label, _, params) in crate::UI_COMMANDS { + if deckcraft_engine::cmd::needs_arguments(params) { + continue; + } + let same: Vec<_> = palette_items(&session, &label.to_lowercase()).into_iter().filter(|(_, l, _)| l == label).collect(); + assert_eq!(same.len(), 1, "{label}"); + assert_eq!(same[0].0, *id, "{label}"); + } + } +} diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 00000000..7714637b --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -0,0 +1,328 @@ +//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and +//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs, +//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is +//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest +//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus. + +use std::sync::Arc; + +use deckcraft_model::edit; +use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody}; +use egui::text::{CCursor, Galley, LayoutJob, TextFormat}; +use egui::{Align2, Color32, Stroke, Ui, vec2}; + +use crate::theme; + +/// Size and colour of a notes editor's text. +pub struct Look { + /// Pixels for 12 pt text, the notes' default size; indents scale with it. + pub size: f32, + /// `None` = the widget text colour. + pub color: Option, +} + +/// Notes being edited: the text in the editor and the formatted notes it stands for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Draft { + pub text: String, + pub body: TextBody, + /// Edited since it was saved. + pub edited: bool, +} + +impl Draft { + pub fn new(body: &TextBody) -> Self { + Draft { text: body.text(), body: body.clone(), edited: false } + } +} + +/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't +/// be mapped onto it. +fn typed(body: &TextBody, text: &str) -> Option { + let mut b = body.clone(); + edit::replace_text(&mut b, text); + (b.text() == text).then_some(b) +} + +/// A length in points from the file as pixels, bounded. +fn px(pt: f64, k: f32) -> f32 { + if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 } +} + +/// Where a paragraph's first line starts and its other lines start (points): a level without its +/// own margin is indented 36 pt per level, as in the default notes master. +fn indents(p: &Paragraph) -> (f64, f64) { + let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8))); + let first = margin + p.props.indent.unwrap_or(0.0); + (first, margin) +} + +/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an +/// unnumbered paragraph at the same or a lower level). +fn bullets(body: &TextBody) -> Vec> { + let mut numbering = [0u32; 9]; + body.paragraphs + .iter() + .map(|p| { + let lvl = usize::from(p.level.min(8)); + if p.is_empty() { + return None; + } + let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. })); + for (i, n) in numbering.iter_mut().enumerate().skip(lvl) { + if i > lvl || !auto { + *n = 0; + } + } + match p.props.bullet.as_ref()? { + Bullet::None => None, + Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())), + Bullet::AutoNum { scheme, start_at } => { + let n = numbering.get_mut(lvl)?; + *n = n.saturating_add(1); + Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n))) + } + Bullet::Picture { .. } => Some("•".into()), + } + }) + .collect() +} + +fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat { + let size = match props.size { + Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0), + _ => size, + }; + let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) }; + let mut f = TextFormat::simple(font, color); + f.italics = props.italic == Some(true); + if props.underline.as_deref().is_some_and(|u| u != "none") { + f.underline = Stroke::new(1.0, color); + } + if matches!(props.strike, Some(Strike::Single | Strike::Double)) { + f.strikethrough = Stroke::new(1.0, color); + } + f +} + +/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at +/// `text`, clear of a bullet. +struct ParaLayout { + job: LayoutJob, + margin: f32, + /// x of the first line's text. + text: f32, +} + +fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout { + let k = size / 12.0; + let (first, margin) = indents(p); + let (first, margin) = (px(first, k), px(margin, k)); + let text = match bullet { + // The text starts at the margin, after the bullet if it's wider than the hanging indent. + Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)), + None => first, + }; + let mut job = LayoutJob::default(); + job.wrap.max_width = (wrap - margin).max(size * 4.0); + let mut lead = (text - margin).max(0.0); + for r in &p.runs { + let t = r.content(); + if !t.is_empty() { + job.append(t, lead, format(&r.props, size, color)); + lead = 0.0; + } + } + if job.text.is_empty() { + job.append("", 0.0, format(&p.end_props, size, color)); + } + ParaLayout { job, margin, text } +} + +/// The whole notes as one job (the galley's text and formats). +fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap; + for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() { + if i > 0 { + job.append("\n", 0.0, TextFormat::simple(theme::font(size), color)); + } + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + for s in pl.job.sections { + if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) { + job.append(t, s.leading_space, s.format); + } + } + } + job +} + +/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up +/// under the text and not under the bullet. +fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc { + let whole = job(body, size, color, wrap); + if body.paragraphs.is_empty() { + return ui.fonts_mut(|f| f.layout_job(whole)); + } + let parts: Vec> = ui.fonts_mut(|f| { + body.paragraphs + .iter() + .zip(bullets(body)) + .map(|(p, bullet)| { + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + let mut g = (*f.layout_job(pl.job)).clone(); + for (i, row) in g.rows.iter_mut().enumerate() { + // The first line already holds the gap after a bullet; a first line hanging + // left of the margin without one starts there. + row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin }; + } + Arc::new(g) + }) + .collect() + }); + Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point())) +} + +/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint, +/// size and frame. Edits update the draft's text and formatted body. Returns the field's output +/// (galley and caret too, for spelling marks). +pub fn editor( + ui: &mut Ui, + draft: &mut Draft, + look: &Look, + configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>, +) -> egui::text_edit::TextEditOutput { + let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color())); + let size = look.size; + let before = draft.body.clone(); + let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) { + Some(b) => galley(ui, &b, size, color, wrap), + None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))), + }; + let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui); + if draft.body.text() != draft.text { + edit::replace_text(&mut draft.body, &draft.text); + draft.edited = true; + } + // Bullets and numbers sit at the start of each paragraph's first line. + if draft.body.text() == draft.text { + let painter = ui.painter().with_clip_rect(out.text_clip_rect); + let k = size / 12.0; + let mut at = 0usize; + for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) { + if let Some(b) = bullet { + let row = out.galley.pos_from_cursor(CCursor::new(at)); + let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y); + let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8); + painter.text(pos, Align2::LEFT_CENTER, b, font, color); + } + at += p.char_len() + 1; + } + } + out +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_model::text::{ParaProps, Run}; + + use super::*; + + /// Notes as PowerPoint users write them: a bold heading, a plain line, points with a + /// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points, + /// a point with an explicit buNone, closing plain lines; Slovak text throughout. + fn notes() -> TextBody { + let props = |bullet: Bullet| ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(bullet), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }; + let point = |runs: Vec, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() }; + let square = || Bullet::Char { char: "§".into() }; + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()), + point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()), + point(vec![Run::new("Jeden")], num()), + point(vec![Run::new("Dva")], num()), + point(vec![Run::new("Bez odrážky")], Bullet::None), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + } + } + + #[test] + fn bullets_follow_the_paragraphs() { + let b = bullets(¬es()); + assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]); + } + + #[test] + fn notes_show_bold_runs_indents_and_wrap_under_the_text() { + let body = notes(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut laid = None; + let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0))); + out.textures_delta.clear(); + let g = laid.unwrap(); + assert_eq!(g.job.text, body.text(), "the galley reads the notes"); + assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::(), body.text().chars().count()); + // Bold where the runs are bold, regular elsewhere. + let font_of = |s: &str| { + let b = g.job.text.find(s).unwrap(); + g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() + }; + assert_eq!(font_of("Metodický"), theme::bold(12.0)); + assert_eq!(font_of("Cieľ"), theme::bold(12.0)); + assert_eq!(font_of("vysvetliť"), theme::font(12.0)); + assert_eq!(font_of("Pomôcky"), theme::font(12.0)); + // Rows: x where each paragraph's text starts. + let row_x = |s: &str| { + let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap(); + g.pos_from_cursor(CCursor::new(at)).min.x + }; + assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented"); + assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ")); + assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0"); + // The long point wraps, and its wrapped lines start at the margin, not under the bullet. + let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap(); + let first_row = g.pos_from_cursor(CCursor::new(long)); + let wrapped: Vec<_> = + g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect(); + assert!(!wrapped.is_empty(), "the long point wraps"); + assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text"); + } + + #[test] + fn typing_keeps_the_formatting_around_it() { + let mut d = Draft::new(¬es()); + // Typing into the bold heading continues it in bold. + let t = d.text.replacen("prehľad", "prehľad hodiny", 1); + d.body = typed(&d.body, &t).unwrap(); + assert_eq!(d.body.paragraphs[0].runs.len(), 1); + assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter at the end of a bulleted point starts another one with the same bullet and indent. + let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1); + d.body = typed(&d.body, &t).unwrap(); + let new = &d.body.paragraphs[3]; + assert_eq!(new.text(), "nový bod"); + assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props)); + assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold"); + // Hostile sizes and indents stay bounded. + let mut wild = notes(); + wild.paragraphs[2].props.margin_left = Some(f64::NAN); + wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY); + let j = job(&wild, 12.0, Color32::BLACK, 300.0); + assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0)); + } +} diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs new file mode 100644 index 00000000..37d20a4e --- /dev/null +++ b/crates/ui-egui/src/opening.rs @@ -0,0 +1,202 @@ +//! Opening a presentation in the background (#350): reading and parsing a large PPTX no longer +//! freezes the window. A modal shows the file being opened and the time so far, and Cancel (or +//! Escape) gives the window back at once; the abandoned read finishes on its thread and is dropped. + +use crate::SlideApp; +use deckcraft_engine::cmd::file::LoadedFile; +use std::sync::mpsc::{Receiver, TryRecvError}; + +/// A presentation being read on a background thread. +pub struct Opening { + pub path: String, + pub started_ms: f64, + rx: Receiver>, +} + +impl Opening { + /// The file name shown in the window. + pub fn name(&self) -> String { + std::path::Path::new(&self.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.path.clone()) + } +} + +impl SlideApp { + /// Open `path` without blocking the UI: the file is read on a thread and the progress window + /// shows until it arrives. Without threads (the web build) it opens at once. + pub fn open_path_in_background(&mut self, path: &str) { + if let Some(o) = &self.opening { + let name = o.name(); + self.set_status(format!("Still opening {name}")); + return; + } + #[cfg(target_arch = "wasm32")] + { + if let Err(e) = self.open_path(path) { + self.notify(e); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let p = path.to_string(); + let spawned = std::thread::Builder::new().name("deckcraft-open".into()).spawn(move || { + let r = deckcraft_engine::cmd::file::read_presentation_file(&p).map_err(|e| e.to_string()); + let _ = tx.send(r); + }); + match spawned { + Ok(_) => self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), rx }), + Err(_) => { + if let Err(e) = self.open_path(path) { + self.notify(e); + } + } + } + } + } + + /// Stop waiting for the file being opened; the presentation stays as it is. + pub fn cancel_opening(&mut self) { + if let Some(o) = self.opening.take() { + self.set_status(format!("Stopped opening {}", o.name())); + } + } + + /// Take the background read's result when it has arrived. + pub(crate) fn poll_opening(&mut self) { + let Some(o) = &self.opening else { return }; + let r = match o.rx.try_recv() { + Ok(r) => r, + Err(TryRecvError::Empty) => return, + Err(TryRecvError::Disconnected) => Err(format!("{}: reading the file stopped unexpectedly", o.path)), + }; + let Some(o) = self.opening.take() else { return }; + match r { + Ok(f) => { + // Into the session through the command path, like file.open: journaled and guarded. + let token = self.session.stage_loaded(f); + match self.session.execute("file.adopt", &serde_json::json!({"path": o.path, "token": token})) { + Ok(_) => self.note_recent(&o.path), + Err(e) => self.notify(e.to_string()), + } + } + // Over the start screen too, like a failed file.open (#346). + Err(e) => self.notify(e), + } + } + + /// The progress window over everything while a file opens. + pub(crate) fn opening_ui(&mut self, ctx: &egui::Context) { + let Some(o) = &self.opening else { return }; + let name = o.name(); + let secs = ((crate::now_ms() - o.started_ms) / 1000.0).max(0.0); + let mut cancel = false; + egui::Modal::new(egui::Id::new("deckcraft-opening")).show(ctx, |ui| { + ui.set_width(320.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(18.0)); + ui.label(format!("Opening {name}…")); + }); + ui.add_space(6.0); + ui.weak(format!("Reading the file: {secs:.0} s")); + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + cancel = ui.button("Cancel").clicked(); + }); + }); + if cancel || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + self.cancel_opening(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use deckcraft_engine::Session; + + fn saved_deck(name: &str, slides: usize) -> String { + let mut s = Session::with_new(); + for _ in 1..slides { + s.execute("slide.new", &serde_json::json!({})).unwrap(); + } + let path = std::env::temp_dir().join(format!("deckcraft-opening-{}-{name}.deckcraft", std::process::id())); + let path = path.to_string_lossy().to_string(); + s.execute("file.saveAs", &serde_json::json!({"path": path})).unwrap(); + path + } + + fn wait(app: &mut SlideApp) { + for _ in 0..500 { + app.poll_opening(); + if app.opening.is_none() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("opening did not finish"); + } + + #[test] + fn opens_in_the_background_and_adds_the_document() { + let path = saved_deck("ok", 3); + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + app.open_path_in_background(&path); + assert!(app.opening.is_some(), "the read runs on a thread"); + wait(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.slides.len(), 3); + assert_eq!(app.ui.recent.first(), Some(&path)); + // It went in through the command path (journaled like file.open). + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("file.adopt")); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn cancel_drops_the_result() { + let path = saved_deck("cancel", 2); + let mut app = SlideApp::new(Session::with_new(), crate::Services::default()); + app.open_path_in_background(&path); + app.cancel_opening(); + assert!(app.opening.is_none()); + std::thread::sleep(std::time::Duration::from_millis(200)); + app.poll_opening(); + assert_eq!(app.session.documents().len(), 1, "nothing is added after Cancel"); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn errors_are_shown_and_a_second_open_waits() { + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + app.open_path_in_background("/nonexistent/deckcraft/missing.pptx"); + app.open_path_in_background("/nonexistent/deckcraft/other.pptx"); + assert!(app.status.as_ref().is_some_and(|(m, _)| m.starts_with("Still opening missing.pptx"))); + wait(&mut app); + assert!(app.session.documents().is_empty()); + assert!(app.toast.as_ref().is_some_and(|t| t.text.contains("missing.pptx")), "the reason is shown"); + } + + #[test] + fn the_progress_window_shows_and_cancel_closes_it() { + use egui_kittest::kittest::Queryable; + let mut app = SlideApp::new(Session::with_new(), crate::Services::default()); + // A read that never answers: the window stays until Cancel. + let (_tx, rx) = std::sync::mpsc::channel(); + app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), rx }); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.get_by_label("Opening Big deck.pptx…"); + harness.get_by_label("Cancel").click(); + harness.run_steps(2); + assert!(harness.state().opening.is_none()); + assert_eq!(harness.state().session.documents().len(), 1); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..39bab078 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -5,6 +5,7 @@ use deckcraft_model::{AnimClass, AnimStart, Fill, ShapeKind}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; +use crate::animtimeline; use crate::icons::{self, Icon}; use crate::ribbon::{cref_param, paint_anim_tile}; use crate::theme::{self, Tokens}; @@ -27,6 +28,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "smartart" => "Text Pane", + "thesaurus" => "Thesaurus", + "revisions" => "Revisions", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,15 +54,130 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "smartart" => smartart(app, ui), + "thesaurus" => thesaurus(app, ui), + "revisions" => revisions(app, ui), _ => {} }); }); } +/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and +/// Shift+Tab demote and promote, Backspace in an empty line removes it. +fn smartart(app: &mut SlideApp, ui: &mut Ui) { + let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { + ui.label("Select a SmartArt graphic to edit its text."); + return; + }; + let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); + let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some(); + if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any { + app.ui.smartart_drafts = (gid.0, texts.clone()); + } + let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1); + let mut action: Option<(&str, Value)> = None; + let mut commit: Option = None; + let mut focus_row: Option = None; + ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(4.0); + for (i, node) in d.nodes.iter().enumerate() { + let Some(draft) = drafts.get_mut(i) else { break }; + let id = egui::Id::new(("smartart_line", gid.0, i)); + ui.horizontal(|ui| { + ui.add_space(16.0 * node.level as f32); + ui.label("•"); + let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY); + let r = ui.add(te); + if app.ui.smartart_focus == Some(i) { + r.request_focus(); + app.ui.smartart_focus = None; + } + if r.gained_focus() || r.has_focus() { + app.ui.smartart_row = i; + } + if r.has_focus() { + let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace)); + if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 { + action = Some(("smartArt.removeNode", json!({"index": i}))); + focus_row = Some(i.saturating_sub(1)); + } + } + if r.lost_focus() { + let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift)); + if tab { + action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i}))); + focus_row = Some(i); + } else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + action = Some(("smartArt.addNode", json!({"index": i, "position": "after"}))); + focus_row = Some(i + 1); + } + commit = Some(i); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("+ Add").clicked() { + action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); + } + if ui.button("Remove").clicked() && !d.nodes.is_empty() { + action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); + } + }); + // Commit a changed line before any structural edit, so the edit sees the new text. + if let Some(i) = commit + && drafts.get(i) != texts.get(i) + { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()})); + } + if action.is_some() { + for (i, t) in drafts.iter().enumerate() { + if Some(i) != commit && texts.get(i) != Some(t) { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t})); + } + } + } + app.ui.smartart_drafts.1 = drafts; + if let Some((cmd, mut p)) = action { + if let Some(o) = p.as_object_mut() { + o.insert("id".into(), json!(gid.0)); + } + if let Ok(r) = app.run(cmd, p) { + let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0); + app.ui.smartart_row = row; + app.ui.smartart_drafts.0 = 0; + app.ui.smartart_focus = Some(row); + } + } +} + fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } +/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s +/// `transparency` parameter writes as an alpha transform. Anything else — no fill, a +/// gradient, a picture, a pattern — is 0, because that command only changes a solid. +fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 { + let Some(Fill::Solid { color }) = fill else { + return 0.0; + }; + ((1.0 - color.alpha()) * 100.0) as f32 +} + +/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus +/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. +fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option { + if !shown_percent.is_finite() { + return None; + } + let shown = shown_percent.clamp(0.0, 100.0); + if (shown - seed_percent).abs() <= 0.01 { + return None; + } + Some(f64::from(shown) / 100.0) +} + fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme { app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors) } @@ -93,6 +212,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { return; }; let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + let uid = st.uid; // Tabs as icons. ui.horizontal(|ui| { for (tab, icon, tip) in [ @@ -130,6 +250,26 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } }); + let fx = sh.effects.clone().unwrap_or_default(); + let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); + if let Some(sw) = shadow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("shadow", sh.id.0, "t"), + "Transparency", + (1.0 - sw.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "shadowProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); + } ui.add_space(6.0); ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { @@ -139,27 +279,66 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } }); + if let Some(r) = &fx.reflection { + fx_slider( + app, + ui, + ("refl", sh.id.0, "t"), + "Transparency", + (1.0 - r.start_alpha) * 100.0, + 0.0..=100.0, + "%", + "reflectionProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); + fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); + fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); + } ui.add_space(6.0); ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); - let mut g = sh.effects.as_ref().and_then(|e| e.glow.as_ref().map(|g| g.radius)).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text("size (pt)")).drag_stopped() { - run(app, "shape.effects", json!({"glow": if g > 0.0 { json!(g) } else { Value::Null }})); + let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); + fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); + if let Some(g) = &fx.glow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("glow", sh.id.0, "t"), + "Transparency", + (1.0 - g.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "glowProps", + "transparency", + 0.01, + ); } ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { run(app, "shape.effects", json!({"softEdges": s})); } + let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); + three_d_options(app, ui, sh.id.0, raw3d.as_deref()); if ui.button("Reset Effects").clicked() { run(app, "shape.effects", json!({"reset": true})); } } "size" => { + let lock_id = egui::Id::new("fmt-lock-aspect"); + let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); + let locked = lock; let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| { ui.horizontal(|ui| { ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { - run(app, cmd, json!({key: nv * unit})); + let mut p = json!({key: nv * unit}); + if cmd == "shape.resize" && locked { + p["lockAspect"] = json!(true); + } + run(app, cmd, p); } }); }; @@ -167,21 +346,32 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0); fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0); fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0); + if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { + ui.data_mut(|d| d.insert_temp(lock_id, lock)); + } ui.add_space(6.0); ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0); fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); ui.add_space(6.0); ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); - let id = ui.id().with(("alt", sh.id.0)); - let mut alt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| sh.descr.clone())); + let id = ui.id().with(("alt", uid, sh.id.0)); + let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); + let mut alt = widgets::text_draft(stored, &sh.descr); let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY)); if r.lost_focus() && alt != sh.descr { run(app, "shape.altText", json!({"text": alt})); } - ui.data_mut(|d| d.insert_temp(id, alt)); + ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => { + ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); + color_button(app, ui, "Color", None, |c| match c { + Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), + None => ("format.color".into(), json!({"color": "tx1"})), + }); + text_transparency_slider(app, ui, &sh); + ui.add_space(6.0); ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); ui.horizontal(|ui| { ui.label("Vertical alignment"); @@ -262,9 +452,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Pattern(_)) => "pattern", _ => "auto", }; - for (l, k) in - [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] - { + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -275,6 +470,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -285,14 +481,24 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { ui.label("Transparency"); - let id = ui.id().with("ftr"); - let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0)); + // Seed from this shape. A shared temp defaulted to 0, so a shape that was + // already 40% transparent showed as opaque, and leaving the slider wrote 0. + let seed = solid_fill_transparency_percent(sh.fill.as_ref()); + let id = ui.id().with(("ftr", sh.id.0)); + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); - ui.data_mut(|d| d.insert_temp(id, v)); if r.drag_stopped() || r.lost_focus() { - let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); + if let Some(t) = fill_transparency_to_apply(v, seed) { + let _ = app.run("shape.fill", json!({"transparency": t})); + } + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() || r.changed() { + ui.data_mut(|d| d.insert_temp(id, v)); } }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } if let Some(Fill::Gradient(g)) = &sh.fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { @@ -335,10 +541,266 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } }); + line_details(app, ui, &sh); } } } +/// A labelled drop-down on the Format Shape pane: picks one of `items` (label, value) and runs +/// `shape.line` with `{key: value}`. +fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); + egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { + for (l, v) in items { + if ui.selectable_label(*v == current, *l).clicked() { + run(app, "shape.line", json!({key: v})); + } + } + }); + }); +} + +/// The rest of PowerPoint's Line page: compound type, cap and join types, and the begin and end +/// arrows with their sizes. +fn line_details(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + use deckcraft_model::style::{Compound, LineCap, LineJoin}; + let l = sh.line.clone().unwrap_or_default(); + let compound = match l.compound.unwrap_or_default() { + Compound::Single => "single", + Compound::Double => "double", + Compound::ThickThin => "thickThin", + Compound::ThinThick => "thinThick", + Compound::Triple => "triple", + }; + line_choice( + app, + ui, + "Compound type", + "compound", + compound, + &[("Single", "single"), ("Double", "double"), ("Thick Thin", "thickThin"), ("Thin Thick", "thinThick"), ("Triple", "triple")], + ); + let cap = match l.cap.unwrap_or_default() { + LineCap::Flat => "flat", + LineCap::Round => "round", + LineCap::Square => "square", + }; + line_choice(app, ui, "Cap type", "cap", cap, &[("Flat", "flat"), ("Round", "round"), ("Square", "square")]); + let join = match l.join.unwrap_or_default() { + LineJoin::Round => "round", + LineJoin::Bevel => "bevel", + LineJoin::Miter => "miter", + }; + line_choice(app, ui, "Join type", "join", join, &[("Round", "round"), ("Bevel", "bevel"), ("Miter", "miter")]); + let arrows = [("None", "none"), ("Arrow", "triangle"), ("Open Arrow", "arrow"), ("Stealth", "stealth"), ("Diamond", "diamond"), ("Oval", "oval")]; + let sizes = [("Small", "sm"), ("Medium", "med"), ("Large", "lg")]; + for (label, key, size_label, size_key, end) in + [("Begin arrow", "head", "Begin size", "headSize", &l.head), ("End arrow", "tail", "End size", "tailSize", &l.tail)] + { + line_choice(app, ui, label, key, end.as_ref().map_or("none", |e| e.kind.as_str()), &arrows); + if let Some(e) = end { + line_choice(app, ui, size_label, size_key, &e.w, &sizes); + } + } +} + +/// A slider for one property of a shape effect: shows `value` (in display units), and when the +/// drag ends runs `shape.effects` with `{key: {prop: shown × scale}}`. +#[allow(clippy::too_many_arguments)] +fn fx_slider( + app: &mut SlideApp, + ui: &mut Ui, + id: (&str, u32, &str), + label: &str, + value: f64, + range: std::ops::RangeInclusive, + suffix: &str, + key: &str, + prop: &str, + scale: f64, +) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + // Hold the value while dragging; otherwise follow the shape. + let sid = ui.id().with(id); + let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)); + if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(sid, v)); + } else { + ui.data_mut(|d| d.remove::(sid)); + } + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + ui.data_mut(|d| d.remove::(sid)); + run(app, "shape.effects", json!({key: {prop: v * scale}})); + } + }); +} + +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + +/// "perspectiveHeroicLeftFacing" → "Perspective heroic left facing". +fn preset_label(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. +fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + for o in options { + if ui.selectable_label(*o == cur, preset_label(o)).clicked() { + run(app, "shape.effects", json!({"threeD": {key: o}})); + } + } + }); + }); +} + +/// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. +fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.effects", json!({"threeD": {key: v}})); + } + }); +} + +/// Format Shape ▸ Effects ▸ 3-D Format and 3-D Rotation. +fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) { + use deckcraft_engine::cmd::three_d::{BEVELS, CAMERAS, LIGHTS, MATERIALS, Shape3d}; + let s3 = raw.map(Shape3d::parse).unwrap_or_default(); + let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); + for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { + three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); + if let Some(b) = b { + three_d_drag(app, ui, " Width", b.w, " pt", &format!("{key}W")); + three_d_drag(app, ui, " Height", b.h, " pt", &format!("{key}H")); + } + } + three_d_drag(app, ui, "Depth", s3.depth, " pt", "depth"); + three_d_drag(app, ui, "Contour", s3.contour, " pt", "contour"); + three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); + three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); + three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); + let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); + three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); + three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); + three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); + three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); + if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { + run(app, "shape.effects", json!({"threeD": {"reset": true}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); @@ -397,6 +859,10 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } }); }); + let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } } Some(Fill::Gradient(g)) => { current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); @@ -426,7 +892,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } } - Some(Fill::Picture(_)) => { + Some(Fill::Picture(pf)) => { if ui.button("Insert picture from file…").clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") @@ -434,6 +900,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { { let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); } + picture_background_options(app, ui, pf); } _ => {} } @@ -444,12 +911,15 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); + let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked() - && let Some(Value::Object(mut o)) = current.clone() - { - o.insert("all".into(), json!(true)); - run(app, "design.background", Value::Object(o)); + if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if let Some(Value::Object(mut o)) = current.clone() { + o.insert("all".into(), json!(true)); + run(app, "design.background", Value::Object(o)); + } else if picture { + run(app, "design.background", json!({"copy": true})); + } } if ui.button("Reset Background").clicked() { run(app, "design.background", json!({"reset": true})); @@ -457,21 +927,99 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } +fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + let alpha = pf.alpha.unwrap_or(1.0); + let mut tr = ((1.0 - alpha) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "design.background", json!({"pictureProps": {"tile": tile}})); + } + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; + for (label, id, value, suffix, scale) in [ + ("Offset X", "bg-ox", *tx, " pt", 1.0), + ("Offset Y", "bg-oy", *ty, " pt", 1.0), + ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), + ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); + if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { + let key = match id { + "bg-ox" => "offsetX", + "bg-oy" => "offsetY", + "bg-sx" => "scaleX", + _ => "scaleY", + }; + run(app, "design.background", json!({"pictureProps": {key: v * scale}})); + } + }); + } + let align_name = match align.as_str() { + "t" => "Top", + "tr" => "Top Right", + "l" => "Left", + "ctr" => "Center", + "r" => "Right", + "bl" => "Bottom Left", + "b" => "Bottom", + "br" => "Bottom Right", + _ => "Top Left", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [ + ("tl", "Top Left"), + ("t", "Top"), + ("tr", "Top Right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom Left"), + ("b", "Bottom"), + ("br", "Bottom Right"), + ] { + if ui.selectable_label(align == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"align": k}})); + } + } + }); + }); + let mirror_name = match flip.as_str() { + "x" => "Horizontal", + "y" => "Vertical", + "xy" => "Both", + _ => "None", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { + if ui.selectable_label(flip == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"mirror": k}})); + } + } + }); + }); +} + fn animation(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let Some(slide) = st.current_slide() else { return }; let anims = slide.animations.clone(); + let size = (st.doc.slide_size.width, st.doc.slide_size.height); + let effects = deckcraft_anim::Timeline::new(slide, size.0, size.1).effects(); let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); ui.horizontal(|ui| { - if ui.button("▶ Play All").clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + if ui.button(if playing { "■ Stop" } else { "▶ Play All" }).clicked() { + run(app, "animation.preview", json!({"on": !playing})); } }); ui.separator(); @@ -479,6 +1027,10 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); return; } + let bars = animtimeline::bars(anims.len(), &effects); + let seconds = animtimeline::ruler_seconds(&bars); + let track_w = (ui.available_width() * 0.45).max(60.0); + let pps = animtimeline::pixels_per_second(track_w, seconds); let mut click = 0; for (i, a) in anims.iter().enumerate() { if a.start == AnimStart::OnClick { @@ -506,20 +1058,70 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false); p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text); - // Timeline bar. - let bar_x = r.max.x - 80.0; - let w = (a.duration_ms as f32 / 1000.0 * 20.0).clamp(4.0, 70.0); - let col = match a.class { - AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F), - AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C), - AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B), - _ => Color32::from_rgb(0x2E, 0x6F, 0xD8), - }; - p.rect_filled( - Rect::from_min_size(pos2(bar_x + (a.delay_ms as f32 / 1000.0 * 20.0).min(40.0), r.center().y - 4.0), vec2(w, 8.0)), - CornerRadius::same(2), - col, - ); + // Advanced timeline bar: drag the body to change the delay, the right edge for the duration. + if let Some(Some(b)) = bars.get(i) { + let col = match a.class { + AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F), + AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C), + AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B), + _ => Color32::from_rgb(0x2E, 0x6F, 0xD8), + }; + let x0 = r.max.x - track_w - 6.0; + let x_of = |sec: f64| x0 + (sec as f32 * pps).min(track_w); + let mut bar = Rect::from_min_max( + pos2(x_of(b.start), r.center().y - 5.0), + pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0), + ); + let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0)); + let body_resp = ui.interact(bar, ui.id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); + body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Timing bar {}", i + 1))); + let edge_resp = ui.interact(edge, ui.id().with(("anim_edge", i)), Sense::drag()); + edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Duration handle {}", i + 1))); + if edge_resp.hovered() || edge_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } else if body_resp.hovered() || body_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // The drag offset is remembered: the pointer's press origin is gone on release. + let dx_id = ui.id().with(("anim_drag_dx", i)); + let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) { + (Some(o), Some(p)) => Some(p.x - o.x), + _ => None, + }); + let dx = match live { + Some(d) if body_resp.dragged() || edge_resp.dragged() => { + ui.data_mut(|m| m.insert_temp(dx_id, d)); + d + } + _ => ui.data_mut(|m| m.get_temp::(dx_id)).unwrap_or(0.0), + }; + if body_resp.drag_stopped() || edge_resp.drag_stopped() { + ui.data_mut(|m| m.remove::(dx_id)); + } + let iter_secs = f64::from(a.duration_ms) / 1000.0; + let factor = if iter_secs > 0.0 && b.span > 0.0 { b.span / iter_secs } else { 1.0 }; + if edge_resp.dragged() || edge_resp.drag_stopped() { + let d = animtimeline::dragged_duration(a.duration_ms, dx, pps, factor); + bar.max.x = x_of(b.start + f64::from(d) / 1000.0 * factor).max(bar.min.x + 4.0); + if edge_resp.drag_stopped() && d != a.duration_ms { + run(app, "animation.timing", json!({"index": i, "duration": d})); + } + } else if body_resp.dragged() || body_resp.drag_stopped() { + let d = animtimeline::dragged_delay(a.delay_ms, dx, pps); + let shift = (f64::from(d) - f64::from(a.delay_ms)) / 1000.0; + let w = bar.width(); + bar.min.x = x_of((b.start + shift).max(0.0)); + bar.max.x = bar.min.x + w; + if body_resp.drag_stopped() && d != a.delay_ms { + run(app, "animation.timing", json!({"index": i, "delay": d})); + } + } + let p = ui.painter(); + p.rect_filled(bar, CornerRadius::same(2), col); + if a.repeat == u32::MAX { + p.text(pos2(bar.max.x + 2.0, bar.center().y), Align2::LEFT_CENTER, "⟳", theme::font(10.0), t.text_dim); + } + } if resp.clicked() { run(app, "edit.select", json!({"ids": [a.shape.0]})); } @@ -545,8 +1147,76 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { } }); } + // Seconds ruler under the bars. + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + let x0 = r.max.x - track_w - 6.0; + let p = ui.painter(); + p.line_segment([pos2(x0, r.min.y + 2.0), pos2(x0 + track_w, r.min.y + 2.0)], Stroke::new(1.0, t.text_faint)); + let every = (seconds / 6).max(1); + for s in (0..=seconds).step_by(every as usize) { + let x = x0 + s as f32 * pps; + if x > x0 + track_w + 0.5 { + break; + } + p.line_segment([pos2(x, r.min.y), pos2(x, r.min.y + 5.0)], Stroke::new(1.0, t.text_faint)); + p.text(pos2(x, r.min.y + 6.0), Align2::CENTER_TOP, format!("{s}s"), theme::font(10.0), t.text_dim); + } let _ = paint_anim_tile; - let _ = Stroke::NONE; +} + +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it. +fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } } fn comments(app: &mut SlideApp, ui: &mut Ui) { @@ -555,83 +1225,350 @@ fn comments(app: &mut SlideApp, ui: &mut Ui) { app.dialog = Some(crate::dialogs::Dialog::new("comment")); } ui.separator(); - let Some(st) = app.session.active() else { return }; - let list = st.current_slide().map(|s| s.comments.clone()).unwrap_or_default(); - if list.is_empty() { - ui.label(egui::RichText::new("No comments on this slide.").color(t.text_dim)); + let threads: Vec<(usize, Vec)> = app + .session + .active() + .map(|st| st.doc.slides.iter().enumerate().filter(|(_, sl)| !sl.comments.is_empty()).map(|(i, sl)| (i, sl.comments.clone())).collect()) + .unwrap_or_default(); + if threads.is_empty() { + ui.label(egui::RichText::new("No comments.").color(t.text_dim)); + return; } - for (i, c) in list.iter().enumerate() { - egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + let focus = app.ui.comment_focus; + for (si, list) in threads { + ui.label(egui::RichText::new(format!("Slide {}", si + 1)).font(theme::bold(12.0)).color(t.text_dim)); + ui.add_space(2.0); + for (i, c) in list.iter().enumerate() { + comment_card(app, ui, si, i, c, focus == Some((si, i))); + ui.add_space(6.0); + } + } +} + +fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) { + let _ = app.run("slide.go", json!({"index": slide})); + app.ui.comment_focus = Some((slide, index)); +} + +fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) { + let t = Tokens::get(ui.ctx()); + let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE }; + let shown = egui::Frame::NONE + .fill(if focused { t.selected } else { t.chrome }) + .stroke(stroke) + .corner_radius(CornerRadius::same(8)) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { + let head = ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); ui.painter().circle_filled(r.center(), 12.0, t.accent); ui.painter().text(r.center(), Align2::CENTER_CENTER, &c.initials, theme::bold(11.0), Color32::WHITE); ui.vertical(|ui| { - ui.label(egui::RichText::new(&c.author).font(theme::bold(12.5))); - ui.label(egui::RichText::new(c.date.replace('T', " ").trim_end_matches('Z')).size(10.5).color(t.text_faint)); + let name = ui.add(egui::Label::new(egui::RichText::new(&c.author).font(theme::bold(12.5))).sense(Sense::click())); + if name.clicked() { + focus_comment(app, slide, index); + } + let when = c.date.replace('T', " ").trim_end_matches('Z').to_string(); + let when = if c.resolved { format!("{when} · Resolved") } else { when }; + ui.label(egui::RichText::new(when).size(10.5).color(t.text_faint)); }); }); - ui.label(&c.text); + if head.response.clicked() { + focus_comment(app, slide, index); + } + let body = ui.add(egui::Label::new(&c.text).sense(Sense::click())); + if body.clicked() { + focus_comment(app, slide, index); + } for r in &c.replies { ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); } ui.horizontal(|ui| { - let id = ui.id().with(("reply", i)); + let id = ui.id().with(("reply", slide, index)); let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { - run(app, "comment.reply", json!({"index": i, "text": txt})); + focus_comment(app, slide, index); + run(app, "comment.reply", json!({"index": index, "text": txt})); txt.clear(); } ui.data_mut(|d| d.insert_temp(id, txt)); if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - run(app, "comment.resolve", json!({"index": i, "resolved": !c.resolved})); + focus_comment(app, slide, index); + run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); } if ui.small_button("🗑").clicked() { - run(app, "comment.delete", json!({"index": i})); + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("comment.delete", json!({"index": index})); + match app.ui.comment_focus { + Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, + Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), + _ => {} + } } }); }); - ui.add_space(6.0); + if focused { + let key = egui::Id::new("comment-focus-scrolled"); + let seen = ui.data(|d| d.get_temp::<(usize, usize)>(key)); + if seen != Some((slide, index)) { + ui.scroll_to_rect(shown.response.rect, Some(egui::Align::Center)); + ui.data_mut(|d| d.insert_temp(key, (slide, index))); + } } } /// Design Ideas: offline suggestions — the current slide in each theme and layout variations. +/// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight +/// slides every frame made the pane cost ~25 ms a frame). +#[derive(Clone, Default)] +struct IdeaThumbs { + key: (u64, u64, usize, u32), + tex: Vec<(String, egui::TextureHandle, u32, u32)>, +} + fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a look for your slides. Ideas are generated on your device.").color(t.text_dim)); + ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let idx = st.selection.slide; + let sel = st.selection.clone(); + let key_doc = (st.uid, st.revision); let w = ui.available_width() - 8.0; - for th in deckcraft_model::theme::builtin_themes() { - let mut d = (*doc).clone(); - if let Some(m) = d.masters.first_mut() { - let m = std::sync::Arc::make_mut(m); - m.theme = th.clone(); + let ppp = ui.ctx().pixels_per_point(); + let px = (w * ppp).round().max(16.0) as u32; + let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); + let themes = deckcraft_model::theme::builtin_themes(); + let cache_id = egui::Id::new("design-idea-thumbs"); + let key = (key_doc.0, key_doc.1, idx, px); + let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default(); + if cache.key != key || cache.tex.is_empty() { + cache = IdeaThumbs { key, tex: vec![] }; + let mut render = |name: String, d: &deckcraft_model::Presentation| { + let (px, h) = crate::textures::fit_size( + (px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); + let img = deckcraft_render::render_slide( + d, + idx, + &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, + ); + let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); + cache.tex.push((name, tex, px, h)); + }; + for (id, _) in &ideas { + let mut d = (*doc).clone(); + if deckcraft_engine::cmd::ideas::apply_idea(&mut d, &sel, idx, id).is_ok() { + render(format!("layout:{id}"), &d); + } } - let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; - let img = deckcraft_render::render_slide( - &d, - idx, - &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, - ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + for th in &themes { + let mut d = (*doc).clone(); + if let Some(m) = d.masters.first_mut() { + std::sync::Arc::make_mut(m).theme = th.clone(); + } + render(format!("theme:{}", th.name), &d); + } + ui.ctx().data_mut(|d| d.insert_temp(cache_id, cache.clone())); + } + let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool { + let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false }; + let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click()); + // On the physical pixel grid, one texel per device pixel (#184). + let at = crate::textures::device_pixel_rect(r, ppp); + let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp)); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( - r, + at, CornerRadius::same(2), Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - if resp.on_hover_text(&th.name).clicked() { + let clicked = resp.on_hover_text(tip).clicked(); + ui.add_space(8.0); + clicked + }; + if ideas.is_empty() { + ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); + } + for (id, label) in &ideas { + if thumb(ui, &format!("layout:{id}"), label) { + run(app, "design.applyIdea", json!({"id": id})); + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); + ui.add_space(4.0); + for th in &themes { + if thumb(ui, &format!("theme:{}", th.name), &th.name) { run(app, "design.theme", json!({"name": th.name})); } - ui.add_space(8.0); } } + +/// The transparency (0–1) of a shape's first text run as stored on it. +pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { + let run = sh.text.as_ref().and_then(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter()).next()); + match run.and_then(|r| r.props.fill.as_ref()) { + Some(Fill::Solid { color }) => 1.0 - color.alpha(), + Some(Fill::Gradient(g)) => g.stops.first().map_or(0.0, |s| 1.0 - s.color.alpha()), + _ => 0.0, + } +} + +/// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). +pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + ui.horizontal(|ui| { + ui.label("Transparency"); + let id = ui.id().with(("text-transparency", sh.id)); + let stored = (text_transparency_of(sh) * 100.0) as f32; + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0})); + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); +} + +#[cfg(test)] +#[path = "smartart_tests.rs"] +mod smartart_tests; + +#[cfg(test)] +#[path = "background_tests.rs"] +mod background_tests; + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use deckcraft_color::{ColorTransform, SchemeSlot}; + use deckcraft_engine::Session; + use deckcraft_model::{ColorRef, Fill, ShapeId}; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + use serde_json::json; + + use super::{fill_transparency_to_apply, solid_fill_transparency_percent}; + use crate::{Services, SlideApp}; + + #[test] + fn solid_fill_transparency_reads_the_alpha_shape_fill_writes() { + assert_eq!(solid_fill_transparency_percent(None), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::None)), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::Group)), 0.0); + let plain = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1)); + assert_eq!(solid_fill_transparency_percent(Some(&plain)), 0.0); + // `shape.fill` transparency 0.4 stores Alpha((1 - 0.4) * 100_000). + let forty = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(60_000))); + let p = solid_fill_transparency_percent(Some(&forty)); + assert!((p - 40.0).abs() < 0.01, "{p}"); + let clear = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0))); + assert!((solid_fill_transparency_percent(Some(&clear)) - 100.0).abs() < 0.01); + // A later alpha wins, matching ColorRef::alpha. + let layered = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0)).with(ColorTransform::Alpha(60_000))); + assert!((solid_fill_transparency_percent(Some(&layered)) - 40.0).abs() < 0.01); + + assert_eq!(fill_transparency_to_apply(40.0, 40.0), None); + assert_eq!(fill_transparency_to_apply(40.005, 40.0), None); + assert_eq!(fill_transparency_to_apply(f32::NAN, 40.0), None); + assert_eq!(fill_transparency_to_apply(55.0, 40.0), Some(0.55)); + assert_eq!(fill_transparency_to_apply(0.0, 40.0), Some(0.0)); + } + + fn solid_alpha(app: &SlideApp, id: u32) -> Option { + let sh = app.session.active()?.shape(ShapeId(id))?; + let Fill::Solid { color } = sh.fill.as_ref()? else { return None }; + color.mods.iter().find_map(|m| match m { + ColorTransform::Alpha(v) => Some(*v), + _ => None, + }) + } + + fn slider_values(h: &Harness<'_, SlideApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect() + } + + /// The Fill slider shows the selected solid's transparency, and opening the pane + /// (or looking at another shape first) does not write 0% over it. + #[test] + fn fill_transparency_slider_follows_the_selection() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.fill", &json!({"id": a, "color": "accent1", "transparency": 0.0})).unwrap(); + s.execute("shape.fill", &json!({"id": b, "color": "accent1", "transparency": 0.4})).unwrap(); + s.execute("edit.select", &json!({"ids": [a]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "fill".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let opaque: Vec = slider_values(&h); + assert!(opaque.iter().any(|v| (*v - 0.0).abs() < 0.5), "opaque shape should show 0%, got {opaque:?}"); + assert!(opaque.iter().all(|v| (*v - 40.0).abs() > 0.5), "opaque shape should not show 40%, got {opaque:?}"); + + h.state_mut().session.execute("edit.select", &json!({"ids": [b]})).unwrap(); + h.run_steps(3); + let clear: Vec = slider_values(&h); + assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}"); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + + h.get_by_label("Transparency").click(); + h.run_steps(2); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + } +} + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} + +#[cfg(test)] +#[path = "thesaurus_tests.rs"] +mod thesaurus_tests; diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs new file mode 100644 index 00000000..fb8e796b --- /dev/null +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -0,0 +1,87 @@ +//! The Paragraph dialog: Home › Paragraph › Paragraph… opens it, OK applies one undo step, +//! and Tabs… edits stops then returns to Paragraph. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn align_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align.map(|a| a.xml().to_string())) +} + +fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.tabs.clone()) + .unwrap_or_default() + .into_iter() + .map(|t| (t.pos, t.align)) + .collect() +} + +#[test] +fn paragraph_button_aligns_text_in_one_undo() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(align_of(&h).as_deref(), Some("r")); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn tabs_from_paragraph_applies_a_stop_and_returns() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("Tabs…").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("stops".into(), "2:ctr".into()); + } + h.get_by_label("OK").click_accesskit(); + // The Tabs OK frame restores Paragraph; the next frame fills its fields. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); + assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); + assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); +} diff --git a/crates/ui-egui/src/photo_album_tests.rs b/crates/ui-egui/src/photo_album_tests.rs new file mode 100644 index 00000000..b97abe4e --- /dev/null +++ b/crates/ui-egui/src/photo_album_tests.rs @@ -0,0 +1,125 @@ +//! Photo Album dialog: the Insert ribbon opens it, and Create inserts the pictures in one step. + +use deckcraft_engine::Session; +use deckcraft_model::ShapeKind; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// 8×8 red PNG. +fn png() -> Vec { + 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 harness(app: SlideApp) -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1200.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn slides(h: &Harness<'_, SlideApp>) -> usize { + h.state().session.active().map(|d| d.doc.slides.len()).unwrap_or(0) +} + +#[test] +fn insert_ribbon_opens_the_dialog_without_editing() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Insert".into(); + let mut h = harness(app); + h.get_by_label("Photo Album…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("photoAlbum")); + assert_eq!(slides(&h), 1); + assert!(h.state().session.active().unwrap().history.undo.is_empty()); +} + +#[test] +fn create_inserts_the_album_in_one_step() { + let bytes = png(); + let services = Services { + pick_open: Some(Box::new(|_| Some("/tmp/cat.png".into()))), + read: Some(Box::new(move |_| Ok(bytes.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + app.run("insert.photoAlbum", json!({})).expect("open"); + let mut h = harness(app); + h.get_by_label("Add Pictures…").click(); + h.run_steps(2); + assert!(h.query_by_label("cat.png").is_some(), "the chosen file is listed"); + h.get_by_label("New Text Box").click(); + h.run_steps(2); + assert!(h.query_by_label("Text: Text Box").is_some()); + h.get_by_label("Create").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Create closes the dialog"); + assert_eq!(slides(&h), 3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.undo.len(), 1); + assert_eq!(st.history.undo[0].label, "Photo Album…"); + let pics: Vec<_> = st.doc.slides[1].shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); + assert_eq!(pics.len(), 1); + assert_eq!(pics[0].line.as_ref().and_then(|l| l.width), Some(1.5)); + let text: Vec<_> = st.doc.slides[2].shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect(); + assert_eq!(text, ["Text Box"]); +} + +#[test] +fn update_reflows_the_open_album() { + let data = deckcraft_engine::cmd::base64_encode(&png()); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.run("insert.photoAlbum", json!({"pictures": [{"name": "hill.png", "data": data}, {"name": "lake.png", "data": data}], "layout": "1"})) + .expect("create"); + assert_eq!(slides_of(&app), 3); + app.run("insert.photoAlbumEdit", json!({})).expect("edit"); + let mut h = harness(app); + assert!(h.query_by_label("hill.png").is_some(), "the album's pictures are listed"); + assert!(h.query_by_label("Update").is_some()); + h.get_by_label("ALL pictures black and white").click(); + h.run_steps(2); + h.get_by_label("1 picture").click(); + h.run_steps(2); + h.get_by_label("2 pictures").click(); + h.run_steps(2); + h.get_by_label("Update").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Update closes the dialog"); + assert_eq!(slides(&h), 2); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); + let pics: Vec<_> = st.doc.slides[1].shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); + assert_eq!(pics.len(), 2); + match &pics[0].kind { + ShapeKind::Picture { fill } => assert!(fill.adjust.grayscale), + _ => panic!("picture"), + } +} + +fn slides_of(app: &SlideApp) -> usize { + app.session.active().map(|d| d.doc.slides.len()).unwrap_or(0) +} + +#[test] +fn create_with_nothing_keeps_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.run("insert.photoAlbum", json!({})).expect("open"); + let mut h = harness(app); + h.get_by_label("Create").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("photoAlbum")); + assert_eq!(slides(&h), 1); + let status = h.state().status.as_ref().map(|s| s.0.as_str()).unwrap_or(""); + assert!(status.contains("picture") || status.contains("text"), "{status}"); +} diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs new file mode 100644 index 00000000..432a004b --- /dev/null +++ b/crates/ui-egui/src/preview.rs @@ -0,0 +1,38 @@ +//! In-canvas animation preview: the current slide's effects played on the editor canvas +//! (Animations ▸ Preview, Animation Pane ▸ Play All). The slide show uses the same mapping. + +use deckcraft_anim::Timeline; +use deckcraft_geom::Vec2; +use deckcraft_model::ShapeId; +use deckcraft_render::{ParaState, ShapeState}; + +/// How long the finished sequence stays on the canvas before the editor draws the slide at rest. +pub const HOLD_SECS: f64 = 0.45; + +/// Shape and paragraph state when `step` main steps are done and the current step has played `t` seconds. +pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { + let a = tl.state(id, None, step, t); + ShapeState { + visible: a.visible, + opacity: a.opacity, + offset: Vec2::new(a.offset_x, a.offset_y), + scale: (a.scale_x, a.scale_y), + rotate: a.rotate, + clip: a.clip, + tint: a.tint, + paras: tl + .paragraph_targets(id) + .into_iter() + .map(|p| { + let a = tl.state(id, Some(p), step, t); + (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) }) + }) + .collect(), + } +} + +/// State on the play-everything clock (preview and Play All). +pub fn shape_state_at(tl: &Timeline, id: ShapeId, time: f64) -> ShapeState { + let (step, t) = tl.locate(time); + shape_state(tl, id, step, t) +} diff --git a/crates/ui-egui/src/qat.rs b/crates/ui-egui/src/qat.rs new file mode 100644 index 00000000..a0b77dbd --- /dev/null +++ b/crates/ui-egui/src/qat.rs @@ -0,0 +1,175 @@ +//! Quick Access Toolbar: the command buttons at the left of the title bar, which the user can +//! add to (right-click a ribbon button, or the toolbar's Customize menu), remove from and reset. + +use egui::Ui; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::SlideApp; +use crate::icons::Icon; + +/// One toolbar button: a command, its parameters, and how it looks. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct QatItem { + pub id: String, + #[serde(skip_serializing_if = "Value::is_null")] + pub params: Value, + pub label: String, + /// An [`Icon::name`]. + pub icon: String, +} + +impl Default for QatItem { + fn default() -> Self { + QatItem { id: String::new(), params: Value::Null, label: String::new(), icon: Icon::More.name().into() } + } +} + +impl QatItem { + pub fn new(id: &str, label: &str, icon: Icon) -> Self { + QatItem { id: id.into(), params: Value::Null, label: label.into(), icon: icon.name().into() } + } + fn icon(&self) -> Icon { + Icon::from_name(&self.icon).unwrap_or(Icon::More) + } + fn same(&self, id: &str, params: &Value) -> bool { + self.id == id && (self.params == *params || (self.params.is_null() && params.as_object().is_some_and(|o| o.is_empty()))) + } +} + +/// The commands the Customize menu offers as checkboxes, in PowerPoint's order. +pub fn common() -> Vec { + vec![ + QatItem::new("app.home", "Home", Icon::Home), + QatItem::new("file.new", "New", Icon::File), + QatItem::new("app.openDialog", "Open", Icon::Folder), + QatItem::new("file.save", "Save", Icon::Save), + QatItem::new("app.print", "Print", Icon::Print), + QatItem::new("edit.undo", "Undo", Icon::Undo), + QatItem::new("edit.redo", "Redo", Icon::Redo), + QatItem::new("show.fromStart", "Start from Beginning", Icon::PlayFromStart), + QatItem::new("app.find", "Find", Icon::Search), + QatItem::new("review.spelling", "Spelling", Icon::Spelling), + QatItem::new("app.exportDialog", "Export", Icon::Export), + ] +} + +/// The toolbar a new install starts with. +pub fn defaults() -> Vec { + // Home, Save, Export, Undo, Redo, Start from Beginning: the title bar's buttons before it was customizable. + let all = common(); + ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"] + .iter() + .filter_map(|id| all.iter().find(|q| q.id == *id).cloned()) + .collect() +} + +/// `app.qat`: `{add: id, params?, label?, icon?} | {remove: id, params?} | {reset: true}` → the toolbar. +pub fn command(app: &mut SlideApp, p: &Value) -> Result { + let params = p.get("params").cloned().unwrap_or(Value::Null); + if p.get("reset").and_then(Value::as_bool) == Some(true) { + app.ui.qat = defaults(); + } + if let Some(id) = p.get("add").and_then(Value::as_str) { + if !crate::ribbon::known(id) { + return Err(format!("unknown command `{id}`")); + } + if !app.ui.qat.iter().any(|q| q.same(id, ¶ms)) { + let base = common().into_iter().find(|q| q.id == id); + let label = p.get("label").and_then(Value::as_str).map(String::from).or_else(|| base.as_ref().map(|b| b.label.clone())); + let label = label.or_else(|| deckcraft_engine::find_command(id).map(|c| c.label.to_string())).unwrap_or_else(|| id.to_string()); + let icon = p.get("icon").and_then(Value::as_str).and_then(Icon::from_name).or_else(|| base.map(|b| b.icon())).unwrap_or(Icon::More); + app.ui.qat.push(QatItem { id: id.into(), params: params.clone(), label, icon: icon.name().into() }); + } + } + if let Some(id) = p.get("remove").and_then(Value::as_str) { + app.ui.qat.retain(|q| !q.same(id, ¶ms)); + } + serde_json::to_value(&app.ui.qat).map_err(|e| e.to_string()) +} + +fn run(app: &mut SlideApp, q: &QatItem) { + match q.id.as_str() { + "file.save" => app.save(), + "show.fromStart" => app.start_show(app.show_start_index(), false), + "review.spelling" => app.dialog = Some(crate::dialogs::Dialog::new("spelling")), + id => { + let p = if q.params.is_null() { json!({}) } else { q.params.clone() }; + let _ = app.run(id, p); + } + } +} + +fn tip(q: &QatItem) -> String { + let sc = + deckcraft_engine::find_command(&q.id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == q.id).and_then(|c| c.2)); + match sc { + Some(s) => format!("{} ({})", q.label, crate::ribbon::pretty_shortcut(s)), + None => q.label.clone(), + } +} + +/// Draw the toolbar and its Customize menu into a left-to-right row of the title bar. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let items = app.ui.qat.clone(); + for q in &items { + let on = q.id == "app.home" && app.backstage_open(); + let en = match q.id.as_str() { + "app.home" => true, + "file.save" | "show.fromStart" | "review.spelling" | "app.find" => app.session.active().is_some(), + id => crate::ribbon::enabled(app, id), + }; + let r = crate::widgets::icon_toggle(ui, q.icon(), &tip(q), en, on); + if r.clicked() { + run(app, q); + } + r.context_menu(|ui| { + if ui.button("Remove from Quick Access Toolbar").clicked() { + let _ = command(app, &json!({"remove": q.id, "params": q.params})); + ui.close(); + } + }); + } + let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false); + egui::Popup::menu(&r).show(|ui| { + ui.label(egui::RichText::new("Customize Quick Access Toolbar").strong()); + for c in common() { + let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null)); + if ui.checkbox(&mut on, &c.label).changed() { + let key = if on { "add" } else { "remove" }; + let _ = command(app, &json!({key: c.id})); + } + } + let extra: Vec = app.ui.qat.iter().filter(|q| !common().iter().any(|c| q.same(&c.id, &Value::Null))).cloned().collect(); + if !extra.is_empty() { + ui.separator(); + for q in extra { + let mut on = true; + if ui.checkbox(&mut on, &q.label).changed() { + let _ = command(app, &json!({"remove": q.id, "params": q.params})); + } + } + } + ui.separator(); + ui.label(egui::RichText::new("Right-click any ribbon button to add it.").weak()); + if ui.button("Reset to Defaults").clicked() { + let _ = command(app, &json!({"reset": true})); + ui.close(); + } + }); +} + +/// The ribbon buttons' right-click menu: add the button to the toolbar. +pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &str, id: &str, p: &Value) { + r.context_menu(|ui| { + let has = app.ui.qat.iter().any(|q| q.same(id, p)); + let text = if has { "Remove from Quick Access Toolbar" } else { "Add to Quick Access Toolbar" }; + if ui.button(text).clicked() { + let label = label.replace('\n', " "); + let key = if has { "remove" } else { "add" }; + let _ = command(app, &json!({key: id, "params": p, "label": label, "icon": icon.name()})); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs new file mode 100644 index 00000000..205c65aa --- /dev/null +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -0,0 +1,71 @@ +//! Home › Replace Fonts…: the dialog lists the deck's fonts and replaces one, as one undo step. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"rect": [40, 40, 400, 80], "text": "Hello fonts"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + s.execute("format.size", &json!({"size": 40})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn run_font(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.current_slide() + .unwrap() + .shapes + .iter() + .find_map(|s| s.text.as_ref()) + .and_then(|b| b.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.font.clone()) +} + +#[test] +fn ribbon_button_opens_the_dialog_and_replaces() { + let mut h = harness(); + h.get_by_label("Replace Fonts…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("replaceFonts")); + // Pick the font to replace and type the new one, the way a person would. + if let Some(d) = h.state_mut().dialog.as_mut() { + d.fields.insert("from".into(), "Comic Neue".into()); + d.fields.insert("to".into(), "Lato".into()); + } + h.run_steps(2); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); + h.run_steps(3); + assert_eq!(run_font(&h).as_deref(), Some("Lato")); + // The dialog stays open with a confirmation, like PowerPoint's. + assert!(h.query_by_label_contains("Replaced “Comic Neue”").is_some()); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(run_font(&h).as_deref(), Some("Comic Neue")); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let mut h = harness(); + h.render().unwrap().save("/tmp/replacefonts0.png").unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + h.run_steps(4); + h.render().unwrap().save("/tmp/replacefonts.png").unwrap(); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..3e43a68b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -12,7 +12,34 @@ use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dro pub const TITLE_H: f32 = 38.0; +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, 16 pt from the top of the window on macOS 27. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is the band they are centred +/// in, with the controls beside them (#71). Up to macOS 15 the lights are smaller and centred 14 pt +/// from the top, about 2 pt above this bar's centre. +pub const MAC_TITLE_BAR: f32 = 32.0; +/// The traffic lights as AppKit lays them out, measured on macOS 27: button diameter, +/// centre-to-centre pitch and the first button's left edge. +const MAC_LIGHT: f32 = 14.0; +const MAC_LIGHT_PITCH: f32 = 23.0; +const MAC_LIGHTS_LEFT: f32 = 9.0; +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// The title bar's height, the left edge of its content and the height of its tallest control. +/// With the integrated title bar (macOS) the bar is shorter, so the controls shrink to keep a +/// margin above and below them. +fn title_geometry(integrated: bool) -> (f32, f32, f32) { + if integrated { (MAC_TITLE_BAR, MAC_CONTENT_LEFT, 22.0) } else { (TITLE_H, 10.0, 26.0) } +} + /// Is engine command `id` enabled right now? +/// Whether `id` names an engine or UI command. +pub fn known(id: &str) -> bool { + deckcraft_engine::find_command(id).is_some() || crate::UI_COMMANDS.iter().any(|c| c.0 == id) +} + pub fn enabled(app: &SlideApp, id: &str) -> bool { match deckcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), @@ -27,7 +54,8 @@ fn run(app: &mut SlideApp, id: &str, p: Value) { /// The window's title row: traffic-light space, quick access, centred title, actions. pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("title_bar").exact_size(TITLE_H).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let (height, left, control_h) = title_geometry(app.integrated_titlebar); + egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title bar moves the window. let drag = ui.interact(rect, ui.id().with("titledrag"), Sense::click_and_drag()); @@ -38,7 +66,6 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } - let left = if app.integrated_titlebar { 84.0 } else { 10.0 }; let mut child = ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) @@ -47,21 +74,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { child.spacing_mut().item_spacing.x = 2.0; let has_doc = app.session.active().is_some(); let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() { - let _ = app.run("app.home", json!({})); - } - if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() { - app.save(); - } - if icon_toggle(&mut child, Icon::Undo, "Undo (⌘Z)", enabled(app, "edit.undo"), false).clicked() { - run(app, "edit.undo", json!({})); - } - if icon_toggle(&mut child, Icon::Redo, "Redo (⌘Y)", enabled(app, "edit.redo"), false).clicked() { - run(app, "edit.redo", json!({})); - } - if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() { - app.start_show(0, false); - } + crate::qat::show(app, &mut child); let title = app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); @@ -78,8 +91,8 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { let share = right.add( egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) .fill(t.accent) - .corner_radius(CornerRadius::same(13)) - .min_size(vec2(84.0, 26.0)), + .corner_radius(control_h / 2.0) + .min_size(vec2(84.0, control_h)), ); if share.clicked() && has_doc { let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); @@ -106,7 +119,11 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { fn contextual(app: &SlideApp) -> Vec<&'static str> { let mut v = vec![]; let Some(st) = app.session.active() else { return v }; - if st.selection.target != Target::Slides { + if st.selection.target == Target::Handout { + v.push("Handout Master"); + } else if st.selection.target == Target::NotesMaster { + v.push("Notes Master"); + } else if st.selection.target != Target::Slides { v.push("Slide Master"); } let shapes = st.selected_shapes(); @@ -122,6 +139,9 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> { v.push("Table Design"); v.push("Layout"); } + if deckcraft_engine::cmd::smartart::selected(st).is_some() { + v.push("SmartArt Design"); + } if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) { v.push("Chart Design"); } @@ -220,8 +240,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "Table Design" => table_design(app, ui), "Layout" => table_layout(app, ui), "Chart Design" => chart_design(app, ui), + "SmartArt Design" => smartart_design(app, ui), "Playback" => playback(app, ui), "Slide Master" => slide_master(app, ui), + "Handout Master" => handout_master(app, ui), + "Notes Master" => notes_master(app, ui), _ => {} } }); @@ -229,16 +252,46 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); - if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() { - run(app, id, p); + let r = big_button(ui, icon, label, en); + if id == "insert.dateTime" { + let name = label.replace('\n', " "); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name)); + } + let r = r.on_hover_text(tip(id, label)); + if r.clicked() { + run(app, id, p.clone()); + } + crate::qat::ribbon_menu(app, &r, icon, label, id, &p); +} + +/// Photo Album is a split button: the main click starts a new album, the arrow edits one. +fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { + let en = enabled(app, "insert.photoAlbum"); + let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); + let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); + main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album…")); + if let Some(a) = &arrow { + let _ = a.clone().on_hover_text("Photo Album options"); + a.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album options")); + } + if main.clicked() { + run(app, "insert.photoAlbum", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({})); + menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({})); + }); } } fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); - if small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)).clicked() { - run(app, id, p); + let r = small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)); + if r.clicked() { + run(app, id, p.clone()); } + crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) { @@ -292,6 +345,9 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { if ui.button("Paste and Match Formatting").clicked() { run(app, "edit.pasteText", json!({})); } + if ui.button("Paste Special…").clicked() { + run(app, "app.pasteSpecial", json!({})); + } if ui.button("Paste Formatting").clicked() { run(app, "format.painterApply", json!({})); } @@ -339,9 +395,9 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke( @@ -380,6 +436,14 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { if ui.button("Slides from Outline…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("outline")); } + let desktop = app.services.pick_open.is_some(); + for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { + let r = ui.add_enabled(desktop, egui::Button::new(label)); + if r.clicked() { + run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); + } + r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + } }); } rows(ui, |ui| { @@ -437,6 +501,12 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.separator(); } } + ui.separator(); + // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). + if ui.button("Rescan Installed Fonts").clicked() { + run(app, "font.rescan", json!({})); + ui.close(); + } }); let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); dropdown(ui, "size", &size_text, 52.0, en, |ui| { @@ -502,6 +572,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } }); }); }); @@ -535,6 +608,83 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { } } +/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected +/// text. +/// A layout gallery grouped by category, each layout as a small cached picture with its name. +/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn +/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the +/// document. +fn smartart_gallery( + app: &mut SlideApp, + ui: &mut Ui, + cmd: &str, + extra: Value, + current: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, +) { + use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; + ui.set_min_width(440.0); + let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; + } + ui.label(egui::RichText::new(title).strong()); + egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { + for (i, (k, label)) in kinds.iter().enumerate() { + let ctx = ui.ctx().clone(); + let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); + let r = ui + .vertical_centered(|ui| { + ui.set_width(100.0); + let r = match &thumb { + Some(t) => { + ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) + } + None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), + }; + ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label)); + r.on_hover_text(*label) + }) + .inner; + if r.hovered() { + let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); + if let Some(t) = preview { + r.show_tooltip_ui(|ui| { + ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); + }); + } + } + if r.clicked() { + let mut args = extra.clone(); + args["kind"] = json!(k); + run(app, cmd, args); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); + ui.separator(); + } + }); +} + fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -556,6 +706,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.bullets", json!({"on": true, "char": ch})); } } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "bulleted".into()); + app.dialog = Some(dlg); + } }); } let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true); @@ -580,6 +736,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.numbering", json!({"on": true, "scheme": sch})); } } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "numbered".into()); + app.dialog = Some(dlg); + } }); } toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); @@ -592,7 +754,10 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } if ui.button("Line Spacing Options…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + run(app, "format.paragraphDialog", json!({})); + } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); @@ -633,6 +798,11 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } }); + let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } }); }); }); @@ -998,6 +1168,18 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); + // Also under Arrange, but that is disabled until something is selected (#132). + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); }); @@ -1027,10 +1209,15 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { app.dialog = Some(crate::dialogs::Dialog::new("table")); ui.close(); } + if ui.button("Draw Table").clicked() { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); + ui.close(); + } }); }); group(ui, |ui| { big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); + photo_album_button(app, ui); let r = big_button(ui, Icon::Screenshot, "Screenshot", false); let _ = r.on_hover_text("Screenshot (not available yet)"); }); @@ -1041,19 +1228,26 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); } }); - let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "shape.insert")); - egui::Popup::menu(&r).width(260.0).show(|ui| icon_library(app, ui)); + let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon")); + egui::Popup::menu(&r).width(340.0).show(|ui| icon_library(app, ui)); big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } } }); }); group(ui, |ui| { + let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); egui::Popup::menu(&r).show(|ui| { @@ -1062,6 +1256,11 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { run(app, "insert.actionButton", json!({"kind": k})); } } + ui.separator(); + let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); + if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("action")); + } }); }); group(ui, |ui| { @@ -1122,53 +1321,98 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } } -/// A small built-in icon library: our own simple pictograms built from preset shapes. +/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. fn icon_library(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let items: [(&str, &str); 24] = [ - ("star5", "Star"), - ("heart", "Heart"), - ("lightningBolt", "Lightning"), - ("sun", "Sun"), - ("moon", "Moon"), - ("cloud", "Cloud"), - ("smileyFace", "Smile"), - ("noSmoking", "Not allowed"), - ("mathPlus", "Plus"), - ("mathMultiply", "Multiply"), - ("mathEqual", "Equals"), - ("chevron", "Chevron"), - ("rightArrow", "Arrow"), - ("quadArrow", "Move"), - ("uturnArrow", "Return"), - ("flowChartDecision", "Decision"), - ("flowChartMagneticDisk", "Database"), - ("flowChartDocument", "Document"), - ("wedgeRoundRectCallout", "Speech"), - ("cloudCallout", "Thought"), - ("teardrop", "Pin"), - ("donut", "Ring"), - ("irregularSeal1", "Burst"), - ("ribbon2", "Banner"), - ]; - ui.horizontal_wrapped(|ui| { - for (preset, label) in items { - let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + let (color, hover) = (t.text, t.hover); + let id = ui.id().with("icon-query"); + let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); + ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); + let q = query.trim().to_lowercase(); + ui.data_mut(|d| d.insert_temp(id, query)); + let icons = deckcraft_engine::icons::icons(); + egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { + if q.is_empty() { + let mut cats: Vec<&str> = Vec::new(); + for icon in icons { + if !cats.contains(&icon.category) { + cats.push(icon.category); + } } - paint_preset(ui.painter(), r.shrink(6.0), preset, t.text, t.text); - if resp.on_hover_text(label).clicked() { - let size = app.session.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let _ = app.run("shape.insert", json!({"preset": preset, "rect": [size.width / 2.0 - 48.0, size.height / 2.0 - 48.0, 96, 96]})); - let _ = app.run("shape.fill", json!({"color": "tx1"})); - let _ = app.run("shape.line", json!({"none": true})); - ui.close(); + for cat in cats { + ui.label(egui::RichText::new(cat).small().strong()); + ui.horizontal_wrapped(|ui| { + for icon in icons.iter().filter(|i| i.category == cat) { + icon_cell(app, ui, icon, color, hover); + } + }); + } + } else { + let mut n = 0; + ui.horizontal_wrapped(|ui| { + for icon in icons { + if icon.name.contains(&q) || icon.label.to_lowercase().contains(&q) { + icon_cell(app, ui, icon, color, hover); + n += 1; + } + } + }); + if n == 0 { + ui.label("No icons match."); } } }); } +fn icon_cell(app: &mut SlideApp, ui: &mut Ui, icon: &deckcraft_engine::icons::Icon, color: Color32, hover: Color32) { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), hover); + } + paint_icon(ui.painter(), r.shrink(6.0), icon, color); + if resp.on_hover_text(icon.label).clicked() { + let _ = app.run("insert.icon", json!({"name": icon.name})); + ui.close(); + } +} + +/// Stroke a Lucide outline into a gallery cell. The line is the design stroke scaled to the cell. +fn paint_icon(p: &egui::Painter, r: Rect, icon: &deckcraft_engine::icons::Icon, color: Color32) { + let Some(src) = deckcraft_engine::icons::outline(icon.name) else { return }; + let mut bp = src.clone(); + let (w, h) = (r.width().max(1.0) as f64, r.height().max(1.0) as f64); + bp.apply_affine(deckcraft_geom::Affine::scale_non_uniform(w / deckcraft_engine::icons::VIEW, h / deckcraft_engine::icons::VIEW)); + let sw = ((r.width() * icon.stroke as f32) / deckcraft_engine::icons::VIEW as f32).clamp(1.0, 3.5); + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&bp, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: egui::epaint::PathStroke::new(sw, color), + })); + } +} + // ---------- Draw ---------- fn draw(app: &mut SlideApp, ui: &mut Ui) { @@ -1200,8 +1444,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { } }); group(ui, |ui| { - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); - let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); let _ = r.on_hover_text("Ink to Text (coming soon)"); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); @@ -1232,9 +1480,36 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { { run(app, "design.theme", json!({"name": n})); } + if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { + run(app, "app.browseThemeDialog", json!({})); + } }); + // Variants: the current theme in its four colour variations. + if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { + let variants = deckcraft_model::theme::theme_variants(&th); + let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); + let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); + let sel = variants.iter().position(|c| c.colors == th.colors.colors); + group(ui, |ui| { + if let Some(i) = widgets::gallery( + ui, + tiles.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(t) = tiles.get(i) { + paint_theme_tile(p, r, t); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) { + run(app, "design.variant", json!({"index": i})); + } + }); + } group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Colors")); egui::Popup::menu(&r).width(240.0).show(|ui| { for cs in deckcraft_model::theme::builtin_color_schemes() { ui.horizontal(|ui| { @@ -1254,14 +1529,35 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } }); } + ui.separator(); + if ui.button("Customize Colors…").clicked() { + run(app, "design.colors", json!({})); + ui.close(); + } }); let r = big_button(ui, Icon::Fonts, "Fonts", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fonts")); egui::Popup::menu(&r).width(220.0).show(|ui| { for fs in deckcraft_model::theme::builtin_font_schemes() { if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { run(app, "design.fonts", json!({"name": fs.name})); } } + ui.separator(); + if ui.button("Customize Fonts…").clicked() { + run(app, "design.fonts", json!({})); + ui.close(); + } + }); + let r = big_button(ui, Icon::ShapeEffects, "Effects", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Effects")); + egui::Popup::menu(&r).width(180.0).show(|ui| { + for fs in deckcraft_model::theme::builtin_format_schemes() { + if ui.button(&fs.name).clicked() { + run(app, "design.effects", json!({"name": fs.name})); + ui.close(); + } + } }); let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); egui::Popup::menu(&r).show(|ui| { @@ -1378,17 +1674,60 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); + ui.close(); } } }); }); group(ui, |ui| { + // Sound in its own column, so the timing column keeps its three rows. + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Sound:"); + let stop = cur.as_ref().is_some_and(|t| t.stop_sound); + let name = cur + .as_ref() + .and_then(|t| t.sound) + .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) + .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); + egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); + if ui.selectable_label(none, "[No Sound]").clicked() { + run(app, "transition.sound", json!({"none": true})); + } + if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { + run(app, "transition.sound", json!({"stopPrevious": true})); + } + ui.separator(); + for (id, label) in deckcraft_engine::sounds::BUILTIN { + if ui.button(label).clicked() { + let lp = cur.as_ref().is_some_and(|t| t.sound_loop); + run(app, "transition.sound", json!({"builtin": id, "loop": lp})); + } + } + ui.separator(); + let can_pick = app.services.pick_open.is_some(); + let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); + if r.clicked() { + app.pick_transition_sound(); + } + r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + ui.separator(); + let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); + let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); + if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { + run(app, "transition.sound", json!({"loop": lp})); + } + }); + }); + }); rows(ui, |ui| { ui.horizontal(|ui| { ui.label("Duration:"); @@ -1446,7 +1785,7 @@ fn option_label(o: &str) -> String { } pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { - let t = Tokens::light(); + let t = Tokens::get(p.ctx()); let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); @@ -1523,13 +1862,10 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { s.animations.iter().find(|a| a.shape == *id).cloned() }); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + run(app, "animation.preview", json!({})); } }); group(ui, |ui| { @@ -1591,18 +1927,23 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); } }); - let opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(option_label(o)).clicked() { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { run(app, "animation.options", json!({"option": o})); + ui.close(); } } ui.separator(); - for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { - if ui.button(l).clicked() { - run(app, "animation.options", json!({"textBuild": b})); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); } } }); @@ -1691,7 +2032,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { } pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { - let t = Tokens::light(); + let t = Tokens::get(p.ctx()); let icon = match class { AnimClass::Entrance => Icon::AnimEntrance, AnimClass::Emphasis => Icon::AnimEmphasis, @@ -1707,7 +2048,7 @@ pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass fn slide_show(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); } if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); @@ -1728,7 +2069,7 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { s.rehearse = true; } @@ -1747,7 +2088,9 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) { run(app, "show.setup", json!({"noNarration": !narr})); } let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); - let _ = ui.checkbox(&mut mc, "Show Media Controls"); + if ui.checkbox(&mut mc, "Show Media Controls").changed() { + run(app, "show.setup", json!({"showMediaControls": mc})); + } }); }); } @@ -1757,7 +2100,7 @@ fn record(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::Cameo, "Cameo", false); let _ = r.on_hover_text("Camera capture is not available yet"); if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { - app.start_show(0, false); + app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { s.rehearse = true; } @@ -1777,11 +2120,27 @@ fn record(app: &mut SlideApp, ui: &mut Ui) { }); } +fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { + let (icon, label, id) = if forward { (Icon::NextComment, "Next", "comment.next") } else { (Icon::PrevComment, "Previous", "comment.prev") }; + if small_button(ui, icon, label, enabled(app, id), false).on_hover_text(tip(id, label)).clicked() { + let params = match app.ui.comment_focus { + Some((slide, index)) if app.session.active().is_some_and(|d| d.selection.slide == slide) => json!({"slide": slide, "index": index}), + _ => json!({}), + }; + if let Ok(v) = app.run(id, params) + && let (Some(slide), Some(index)) = (v.get("slide").and_then(Value::as_u64), v.get("index").and_then(Value::as_u64)) + && let (Ok(slide), Ok(index)) = (usize::try_from(slide), usize::try_from(index)) + { + app.ui.comment_focus = Some((slide, index)); + app.ui.pane = Some("comments".into()); + } + } +} + fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); @@ -1791,8 +2150,25 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { let _ = r.on_hover_text("Translation needs a language model (not bundled)"); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); group(ui, |ui| { big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); + rows(ui, |ui| { + comment_nav(app, ui, false); + comment_nav(app, ui, true); + }); big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); let show = app.ui.pane.as_deref() == Some("comments"); if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { @@ -1822,10 +2198,14 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); } - let r = big_button(ui, Icon::HandoutMaster, "Handout\nMaster", false); - let _ = r.on_hover_text("Handout Master (coming soon)"); - let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false); - let _ = r.on_hover_text("Notes Master (coming soon)"); + let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout); + if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { + let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); + } + let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); + if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { + let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); + } }); group(ui, |ui| { rows(ui, |ui| { @@ -1842,24 +2222,45 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { app.ui.guides = gd; } }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } let n = app.ui.notes; if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { app.ui.notes = !n; } }); group(ui, |ui| { - let r = big_button(ui, Icon::ZoomGlass, "Zoom", true); - egui::Popup::menu(&r).show(|ui| { - for pct in [400, 200, 150, 100, 75, 66, 50, 33] { - if ui.button(format!("{pct}%")).clicked() { - let _ = app.run("view.zoom", json!({"percent": pct})); + let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); + if main.on_hover_text("Zoom").clicked() { + let _ = app.run("view.zoomDialog", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + for pct in [400, 200, 150, 100, 75, 66, 50, 33] { + if ui.button(format!("{pct}%")).clicked() { + let _ = app.run("view.zoom", json!({"percent": pct})); + ui.close(); + } } - } - }); + if ui.button("Fit").clicked() { + let _ = app.run("view.fit", json!({})); + ui.close(); + } + }); + } if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { app.ui.zoom = None; } }); + group(ui, |ui| { + let has = app.session.active().is_some(); + let on = app.doc_window.is_some(); + let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; + if resp.on_hover_text("Open another window on this presentation").clicked() { + let _ = app.run("view.newWindow", json!({})); + } + }); group(ui, |ui| { let gs = app.ui.grayscale; if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { @@ -1874,6 +2275,105 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { // ---------- contextual tabs ---------- +/// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. +fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { + let current = selected_warp(app); + ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); + ui.label("Follow Path"); + let mut picked = false; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + ui.label("Warp"); + egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + if warp_tile(ui, "", "No Transform", current.is_none()) { + run(app, "format.textWarp", json!({"preset": "none"})); + picked = true; + } + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| !deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + }); + if picked { + ui.close(); + } +} + +fn selected_warp(app: &SlideApp) -> Option { + let doc = app.session.active()?; + let id = doc.selection.text.as_ref().map(|t| t.shape).or_else(|| doc.selection.shapes.first().copied())?; + doc.shape(id)?.text.as_ref()?.body.warp.clone() +} + +fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 28.0), Sense::click()); + if selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.selected); + } else if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); + resp.on_hover_text(label).clicked() +} + +/// The preset's guides, or two straight lines for No Transform. +fn paint_warp_icon(p: &egui::Painter, r: Rect, name: Option<&str>, color: Color32) { + let Some(name) = name else { + let y0 = r.min.y + r.height() * 0.32; + let y1 = r.min.y + r.height() * 0.68; + p.line_segment([pos2(r.min.x, y0), pos2(r.max.x, y0)], Stroke::new(1.0, color)); + p.line_segment([pos2(r.min.x, y1), pos2(r.max.x, y1)], Stroke::new(1.0, color)); + return; + }; + let rect = deckcraft_geom::Rect::new((r.min.x + 1.0) as f64, (r.min.y + 1.0) as f64, (r.max.x - 1.0) as f64, (r.max.y - 1.0) as f64); + let Some(w) = deckcraft_geom::warp::warp(name, &[], rect) else { return }; + for band in &w.bands { + match band { + deckcraft_geom::warp::Band::Fill { top, bottom } => { + stroke_guide(p, top, color); + stroke_guide(p, bottom, color); + } + deckcraft_geom::warp::Band::Follow { path, .. } => stroke_guide(p, path, color), + } + } +} + +fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color32) { + let len = g.length(); + if !(len.is_finite() && len > 1.0) { + return; + } + let n = 20i32; + let mut pts = Vec::with_capacity((n + 1) as usize); + for i in 0..=n { + let (q, _) = g.at(len * f64::from(i) / f64::from(n)); + if q.x.is_finite() && q.y.is_finite() { + pts.push(pos2(q.x as f32, q.y as f32)); + } + } + if pts.len() >= 2 { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: pts, + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, color).into(), + })); + } +} + fn shape_format(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let sel = enabled(app, "shape.fill"); @@ -1891,6 +2391,13 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } + ui.separator(); + if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { + run(app, "shape.editPoints", json!({"on": true})); + ui.close(); + } + ui.separator(); + smooth_corners_row(app, ui); }); let can_merge = enabled(app, "shape.merge"); let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); @@ -1943,6 +2450,10 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { run(app, "format.color", json!({"color": cref_param(&c)})); } + if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { + ui.separator(); + crate::panes::text_transparency_slider(app, ui, &sh); + } }); } let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); @@ -1954,10 +2465,13 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { }); } let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); - egui::Popup::menu(&r).show(|ui| { + egui::Popup::menu(&r).width(312.0).show(|ui| { if ui.button("Shadow").clicked() { run(app, "format.textShadow", json!({})); + ui.close(); } + ui.separator(); + transform_gallery(app, ui); }); }); }); @@ -2044,13 +2558,22 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { } }); }); + if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { + run(app, "shape.sizePosition", json!({})); + } }); } fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { - let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", false); - let _ = r.on_hover_text("Remove Background (coming soon)"); + let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); + egui::Popup::menu(&r).show(|ui| { + for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { + if ui.button(label).clicked() { + run(app, "picture.removeBackground", json!({"tolerance": tol})); + } + } + }); let r = big_button(ui, Icon::Corrections, "Corrections", true); egui::Popup::menu(&r).show(|ui| { for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { @@ -2072,8 +2595,49 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "picture.adjust", json!({"saturation": s})); } } - if ui.button("Grayscale").clicked() { - run(app, "picture.adjust", json!({"grayscale": true})); + ui.separator(); + ui.label("Recolor"); + let sc = scheme(app); + let presets = deckcraft_engine::cmd::recolor::presets(); + let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + presets.len(), + vec2(40.0, 30.0), + None, + |p, r, i| { + if let Some((id, _)) = presets.get(i) { + paint_recolor(p, r, id, &sc); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = presets.get(i) + { + run(app, "picture.recolor", json!({"preset": id})); + } + ui.separator(); + ui.menu_button("Set Transparent Color", |ui| { + ui.label("Pixels of this colour become transparent:"); + if let Some(c) = widgets::color_grid(ui, &sc, Some("None")) { + match c { + Some(c) => { + let rgb = c.resolve(&sc, None); + run(app, "picture.transparentColor", json!({"color": format!("#{}", rgb.hex())})); + } + None => run(app, "picture.transparentColor", json!({"none": true})), + } + } + }); + }); + let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); + egui::Popup::menu(&r).show(|ui| { + if ui.button("None").clicked() { + run(app, "picture.artisticEffect", json!({"effect": "none"})); + } + for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { + if ui.button(*l).clicked() { + run(app, "picture.artisticEffect", json!({"effect": e})); + } } }); let r = big_button(ui, Icon::Transparency, "Transparency", true); @@ -2085,34 +2649,68 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } }); rows(ui, |ui| { - small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({})); + small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); + let (_, r) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); + if let Some(r) = r { + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(260.0).show(|ui| compress_menu(app, ui)); + } }); }); group(ui, |ui| { - let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true); - egui::Popup::menu(&r).show(|ui| { - if ui.button("Simple Frame, White").clicked() { - run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6})); - run(app, "shape.effects", json!({"shadow": "outer"})); - } - if ui.button("Drop Shadow Rectangle").clicked() { - run(app, "shape.effects", json!({"shadow": "bottom"})); - } - if ui.button("Rounded Diagonal Corner").clicked() { - run(app, "shape.change", json!({"preset": "round2DiagRect"})); - } - if ui.button("Soft Edge Rectangle").clicked() { - run(app, "shape.effects", json!({"softEdges": 10})); - } - if ui.button("Reflected Rounded Rectangle").clicked() { - run(app, "shape.change", json!({"preset": "roundRect"})); - run(app, "shape.effects", json!({"reflection": "half"})); - } - if ui.button("Oval").clicked() { - run(app, "shape.change", json!({"preset": "ellipse"})); + use deckcraft_engine::cmd::picture_style::{STYLES, look}; + let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); + if let Some(i) = widgets::gallery( + ui, + STYLES.len(), + vec2(56.0, 44.0), + None, + |p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))), + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = STYLES.get(i) + { + run(app, "picture.style", json!({"style": id})); + } + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); + if m.clicked() { + run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + match c { + Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), + None => run(app, "shape.line", json!({"none": true})), + } } + ui.separator(); + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); }); + if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { + run(app, "picture.style", json!({"style": "none"})); + } big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); }); arrange_group(app, ui); @@ -2224,6 +2822,136 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { } }); }); + group(ui, |ui| { + let r = big_button(ui, Icon::Borders, "Borders", true); + egui::Popup::menu(&r).show(|ui| { + let pen = app.table_pen.clone(); + for (label, which) in [ + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ("Left Border", "left"), + ("Right Border", "right"), + ("Inside Horizontal Border", "insideH"), + ("Inside Vertical Border", "insideV"), + ("Diagonal Down Border", "diagDown"), + ("Diagonal Up Border", "diagUp"), + ] { + let mut p = pen.clone(); + p["which"] = json!(which); + menu_item(app, ui, Icon::Borders, label, "table.borders", p); + } + ui.separator(); + ui.menu_button("Pen Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); + if ui.radio(cur, format!("{w} pt")).clicked() { + app.table_pen["width"] = json!(w); + } + } + }); + ui.menu_button("Pen Style", |ui| { + for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { + let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; + if ui.radio(cur, l).clicked() { + app.table_pen["dash"] = json!(d); + } + } + }); + ui.menu_button("Pen Color", |ui| { + let sc = scheme(app); + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + app.table_pen["color"] = cref_param(&c); + } + }); + }); + }); + // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. + group(ui, |ui| { + use deckcraft_engine::ToolKind; + use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; + let cur = app.session.tool.kind.clone(); + for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { + let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); + } + } + }); +} + +/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. +fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option) { + let Some(lk) = lk else { return }; + let r = r.shrink(7.0); + if lk.shadow.is_some() { + p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60)); + } + let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5); + let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c))); + let edge = Stroke::new(0.5, Color32::from_gray(150)); + match lk.preset { + "ellipse" => { + p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky)); + p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge))); + } + preset => { + let round = if preset == "rect" { 0 } else { 6 }; + p.rect_filled(r, CornerRadius::same(round), sky); + p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle); + } + } + // Hills, so the tile reads as a picture. + let base = r.bottom() - r.height() * 0.15; + p.line_segment( + [pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + p.line_segment( + [pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + if lk.reflection { + p.rect_filled( + Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)), + CornerRadius::ZERO, + sky.gamma_multiply(0.3), + ); + } +} + +/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it. +fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { + let mut adj = deckcraft_model::style::PictureAdjust::default(); + deckcraft_engine::cmd::recolor::apply(&mut adj, id); + let r = r.shrink(3.0); + let n = 8; + for k in 0..n { + let t = k as f64 / (n - 1) as f64; + // A colourful ramp, so "No Recolor" shows colour and the presets show their mapping. + let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0]; + let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2]; + let mut rgb = if adj.grayscale { [y; 3] } else { src }; + if adj.brightness != 0.0 || adj.contrast != 0.0 { + rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0); + } + if let Some((d, l)) = &adj.duotone { + let (d, l) = (d.resolve(sc, None), l.resolve(sc, None)); + let u = y / 255.0; + rgb = + [d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u]; + } + if let Some(th) = adj.bilevel { + rgb = [if y >= th * 255.0 { 255.0 } else { 0.0 }; 3]; + } + let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); + let w = r.width() / n as f32; + p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); + } + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); } pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { @@ -2290,10 +3018,18 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { - run(app, "format.anchor", json!({"anchor": a})); + if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { + run(app, "table.cellAlign", json!({"anchor": a})); } } + ui.menu_button("Cell Margins", |ui| { + for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { + if ui.button(l).clicked() { + run(app, "table.cellMargins", json!({"preset": preset})); + ui.close(); + } + } + }); }); }); }); @@ -2304,7 +3040,11 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; if ui.button(k.label()).clicked() { // Replace the chart's type keeping its data. if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { @@ -2318,6 +3058,96 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { arrange_group(app, ui); } +/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. +fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { + let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); + let row = app.ui.smartart_row; + let (gid, n, kind, style, colors) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), + None => (None, 0, String::new(), String::new(), String::new()), + }; + let at = row.min(n.saturating_sub(1)); + group(ui, |ui| { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); + } + rows(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } + } + }); + if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { + run(app, "smartArt.promote", json!({"id": gid, "index": at})); + } + if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { + run(app, "smartArt.demote", json!({"id": gid, "index": at})); + } + }); + let pictures = kind.starts_with("picture"); + if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) + .on_hover_text("Put a picture in the selected node's picture placeholder") + .clicked() + { + run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); + } + }); + group(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); + let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); + egui::Popup::menu(&r).show(|ui| { + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); + }); + let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Convert to Text").clicked() { + run(app, "smartArt.toText", json!({"id": gid})); + } + if ui.button("Convert to Shapes").clicked() { + run(app, "smartArt.toShapes", json!({"id": gid})); + } + }); + }); + group(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } + let on = colors == k || (colors.is_empty() && k == "colorful"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + } + } + }); + let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); + egui::Popup::menu(&r).show(|ui| { + for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } + let on = style == k || (style.is_empty() && k == "flat"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.style", json!({"id": gid, "style": k})); + } + } + }); + }); + arrange_group(app, ui); +} + fn playback(app: &mut SlideApp, ui: &mut Ui) { let m = app .session @@ -2372,12 +3202,42 @@ fn playback(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Preserve, and whether the open master or layout has a title or any footer placeholder. +fn master_checks(app: &SlideApp) -> (bool, bool, bool) { + let Some(st) = app.session.active() else { return (false, false, false) }; + let preserve = match st.selection.target { + Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), + Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), + Target::Slides | Target::Handout | Target::NotesMaster => false, + }; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); + (preserve, title, footers) +} + fn slide_master(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); }); + group(ui, |ui| { + rows(ui, |ui| { + let (preserve, title, footers) = master_checks(app); + let mut on = preserve; + if ui.checkbox(&mut on, "Preserve").changed() { + run(app, "master.preserve", json!({"preserve": on})); + } + let mut on = title; + if ui.checkbox(&mut on, "Title").changed() { + run(app, "master.placeholders", json!({"title": on})); + } + let mut on = footers; + if ui.checkbox(&mut on, "Footers").changed() { + run(app, "master.placeholders", json!({"footers": on})); + } + }); + }); group(ui, |ui| { let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); egui::Popup::menu(&r).show(|ui| { @@ -2398,6 +3258,10 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.theme", json!({"name": th.name})); } } + ui.separator(); + if ui.button("Browse for Themes…").clicked() { + run(app, "app.browseThemeDialog", json!({})); + } }); big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); }); @@ -2408,6 +3272,102 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +fn notes_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + use deckcraft_model::PhType; + let shown = |app: &SlideApp, k: PhType| { + app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + for col in [ + [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], + [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], + ] { + rows(ui, |ui| { + for (label, key, k) in col { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "notes.placeholders", json!({ key: on })); + } + } + }); + } + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +fn handout_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); + egui::Popup::menu(&r).show(|ui| { + for n in [1u8, 2, 3, 4, 6, 9] { + if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { + app.ui.handout_per_page = n; + } + } + }); + }); + group(ui, |ui| { + let shown = |app: &SlideApp, k: deckcraft_model::PhType| { + app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + rows(ui, |ui| { + use deckcraft_model::PhType; + for (label, key, k) in [ + ("Header", "header", PhType::Header), + ("Date", "date", PhType::Date), + ("Footer", "footer", PhType::Footer), + ("Page Number", "pageNumber", PhType::SlideNum), + ] { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "handout.placeholders", json!({ key: on })); + } + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +/// The stored id is the one marked row. Every other id stays unmarked. +fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} + +/// One row of Animations ▸ Effect Options for the selected effect. +fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } +} + /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); @@ -2420,3 +3380,240 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::anim::TextBuild; + + /// Paints one transition tile and one animation tile and returns the colour of every label. + fn tile_label_colours(dark: bool) -> Vec { + let ctx = egui::Context::default(); + ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() }); + let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(80.0, 60.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint_transition_tile(ui.painter(), r, ("fade", "Fade")); + paint_anim_tile(ui.painter(), r, "Appear", AnimClass::Entrance, true); + }); + out.textures_delta.clear(); + out.shapes + .iter() + .flat_map(|c| match &c.shape { + egui::Shape::Text(s) => s.galley.job.sections.iter().map(|x| x.format.color).collect(), + _ => Vec::new(), + }) + .collect() + } + + #[test] + fn ribbon_tile_labels_follow_the_theme() { + // #159: the transition and animation tiles were always painted with the light palette, + // so their labels were dark grey on the dark ribbon. + let dark = tile_label_colours(true); + assert_eq!(dark, vec![Tokens::dark().text; 2]); + let light = tile_label_colours(false); + assert_eq!(light, vec![Tokens::light().text; 2]); + } + + fn marked(rows: Vec<(&str, bool)>) -> Vec<&str> { + rows.into_iter().filter(|(_, on)| *on).map(|(id, _)| id).collect() + } + + #[test] + fn effect_options_mark_only_the_stored_choice() { + assert_eq!(marked(transition_effect_rows("push", Some("l"))), vec!["l"]); + assert_eq!(transition_effect_rows("push", Some("l")).into_iter().map(|(_, on)| on).collect::>(), vec![false, false, true, false]); + assert_eq!(marked(transition_effect_rows("fade", Some("throughBlack"))), vec!["throughBlack"]); + assert!(marked(transition_effect_rows("push", Some("missing"))).is_empty()); + assert!(transition_effect_rows("none", None).is_empty()); + assert_eq!(marked(transition_effect_rows("cut", Some(""))), vec![""]); + assert_eq!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("l"))), vec!["l"]); + assert!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("nope"))).is_empty()); + assert!(animation_effect_rows("appear", AnimClass::Entrance, Some("l")).is_empty()); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByParagraph)))), vec!["byParagraph"]); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::AsOne)))), vec!["asOne"]); + assert!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByWord)))).is_empty()); + } +} + +#[cfg(test)] +#[path = "effects_tests.rs"] +mod effects_tests; + +#[cfg(test)] +#[path = "master_layout_tests.rs"] +mod master_layout_tests; + +#[cfg(test)] +mod title_bar_tests { + use super::*; + + #[test] + fn mac_title_bar_centres_and_clears_the_traffic_lights() { + let (h, left, control) = title_geometry(true); + // AppKit centres the lights in its own band, so the bar is that band. + assert_eq!(h, MAC_TITLE_BAR); + // Content keeps the same gap after the lights as the lights keep from the top. + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(left - lights_end, (h - MAC_LIGHT) / 2.0); + // The tallest controls keep at least 4 pt above and below. + assert!(control <= h - 8.0); + // Other platforms keep their system frame and the original bar. + assert_eq!(title_geometry(false), (TITLE_H, 10.0, 26.0)); + } +} + +#[cfg(test)] +#[path = "drawtable_tests.rs"] +mod drawtable_tests; + +#[cfg(test)] +#[path = "inktoshape_tests.rs"] +mod inktoshape_tests; + +/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a +/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui +/// memory, per shape) so later moves re-smooth the same corners. +fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let Some((id, radius)) = app.session.active().and_then(|d| { + let id = *d.selection.shapes.first()?; + let sh = d.shape(id)?; + let x = sh.xfrm?; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { + Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) + } + deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + Some((id, own)) + }) else { + return; + }; + let key = egui::Id::new(("deckcraft-smooth-corners", id.0)); + let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); + if let Some(r) = radius { + state.0 = r; + } + let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); + ui.horizontal(|ui| { + ui.label("Smooth Corners"); + let mut pct = state.1; + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()); + let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + if resp.changed() { + state.1 = pct; + ui.data_mut(|d| d.insert_temp(key, state)); + } + // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); + } + }); +} + +/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's +/// Insert Zoom dialogs do (a checklist of slides or sections). +fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let id = egui::Id::new("insert_zoom_menu"); + let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); + ui.horizontal(|ui| { + for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { + if ui.selectable_label(kind == k, label).clicked() && kind != k { + kind = k; + picked.clear(); + } + } + }); + ui.separator(); + let items: Vec = if kind == 1 { + doc.sections.iter().map(|s| s.name.clone()).collect() + } else { + doc.slides + .iter() + .enumerate() + .map(|(i, s)| { + let t = s.title(); + format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) + }) + .collect() + }; + if items.is_empty() { + ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); + } + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, label) in items.iter().enumerate() { + let mut on = picked.contains(&i); + if ui.checkbox(&mut on, label).changed() { + if on { + picked.push(i); + } else { + picked.retain(|x| *x != i); + } + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); + ui.separator(); + if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { + let mut v = picked.clone(); + v.sort_unstable(); + let p = match kind { + 1 => json!({"kind": "section", "sections": v}), + 2 => json!({"kind": "summary", "slides": v}), + _ => json!({"kind": "slide", "slides": v}), + }; + run(app, "insert.zoom", p); + ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); + ui.close(); + } +} + +/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or +/// all, delete cropped areas. The choices last for the session. +fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { + let id = egui::Id::new("compress_pictures_opts"); + let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); + ui.label(egui::RichText::new("Compression options").strong()); + ui.checkbox(&mut only_selected, "Apply only to this picture"); + ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); + ui.separator(); + ui.label(egui::RichText::new("Resolution").strong()); + for (v, label) in [ + (330, "HD (330 ppi): good quality for high-definition displays"), + (220, "Print (220 ppi): excellent quality on most printers and screens"), + (150, "Web (150 ppi): good for web pages and projectors"), + (96, "E-mail (96 ppi): minimise document size for sharing"), + ] { + ui.radio_value(&mut ppi, v, label); + } + ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); + ui.separator(); + if ui.button("Compress").clicked() { + let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); + match app.session.execute("picture.compress", &p) { + Ok(r) => { + let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; + let msg = match r["pictures"].as_u64().unwrap_or(0) { + 0 => "Pictures are already at or below that resolution".to_string(), + n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), + }; + app.set_status(msg); + } + Err(e) => app.set_status(e.to_string()), + } + ui.close(); + } +} diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs new file mode 100644 index 00000000..7c313563 --- /dev/null +++ b/crates/ui-egui/src/ruler_tests.rs @@ -0,0 +1,160 @@ +//! The ruler's indent and tab markers driven with the mouse while editing text. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { + h.state_mut().session.execute("format.ruler", &json!({})).unwrap() +} + +#[test] +fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Name\tRole\tTeam"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + // The horizontal ruler runs along the top of the canvas. + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + + // First-line indent triangle (top) to 36 pt. + drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0)); + let r = ruler(&mut h); + assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}"); + assert_eq!(r["marginLeft"].as_f64(), Some(0.0)); + + // Hanging triangle (middle) to 18 pt: the first line stays at 36. + drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0)); + let r = ruler(&mut h); + let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap()); + assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}"); + + // Click on the ruler: a left tab; drag it along; drag it off to remove it. + click(&mut h, Pos2::new(rx(150.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 150.0).abs() <= 1.0); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0)); + let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 220.0).abs() <= 1.0, "{pos}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler.png"); + } + assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text"); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); + assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); +} + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs index 414a61f5..36e634c7 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/selpane.rs @@ -28,6 +28,18 @@ pub struct State { pub anchor: Option, /// Row being dragged and the drop it would make: (index among siblings, line y). pub drag: Option<(u32, Option<(usize, f32)>)>, + /// Document (by uid) the state above belongs to. + owner: Option, +} + +impl State { + /// Ties the pane state to a document: shape ids repeat between documents, so switching + /// documents drops the pending rename, folds, anchor and drag. + pub fn bind(&mut self, uid: u64) { + if self.owner != Some(uid) { + *self = State { owner: Some(uid), ..State::default() }; + } + } } /// One line of the list. @@ -108,6 +120,7 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; + app.selpane.bind(st.uid); let rows = rows(st.shapes(), &app.selpane.collapsed); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); let has_sel = !selected.is_empty(); @@ -388,6 +401,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { mod tests { use super::*; + #[test] + fn switching_document_drops_a_pending_rename() { + let mut s = State::default(); + s.bind(1); + s.renaming = Some((5, "draft".to_string())); + s.collapsed.insert(2); + s.bind(1); + assert!(s.renaming.is_some(), "same document keeps the draft"); + s.bind(2); + assert!(s.renaming.is_none()); + assert!(s.collapsed.is_empty()); + } + fn r(id: u32, top: f32) -> (u32, Rect) { (id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H))) } diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs new file mode 100644 index 00000000..9a1b8b62 --- /dev/null +++ b/crates/ui-egui/src/settings.rs @@ -0,0 +1,170 @@ +//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json` +//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a +//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts +//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a +//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing +//! goes through a temporary file and a rename, so a crash mid-write leaves the old file. + +use std::path::Path; + +use deckcraft_engine::Prefs; + +use crate::UiState; + +pub const UI_FILE: &str = "ui.json"; +pub const PREFS_FILE: &str = "prefs.json"; +/// Larger settings files are not settings. +pub const MAX_BYTES: u64 = 4 << 20; + +/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`. +fn read(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + log::warn!("{}: not a settings file; using the defaults", path.display()); + return None; + } + let bytes = std::fs::read(path).ok()?; + match serde_json::from_slice(&bytes) { + Ok(v) => Some(v), + Err(e) => { + log::warn!("{}: {e}; using the defaults", path.display()); + None + } + } +} + +/// The saved UI state and preferences in `dir` (each `None` when missing or unusable). +pub fn load(dir: &Path) -> (Option, Option) { + (read::(&dir.join(UI_FILE)).map(UiState::sanitized), read::(&dir.join(PREFS_FILE)).map(Prefs::sanitized)) +} + +/// Write `bytes` to `path` through a temporary file beside it. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +/// Save both files into `dir` (created when missing). Failures are logged, not fatal. +pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) { + if let Err(e) = std::fs::create_dir_all(dir) { + log::warn!("{}: {e}; settings not saved", dir.display()); + return; + } + for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] { + let path = dir.join(name); + if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) { + log::warn!("{}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn damaged_settings_files_give_the_defaults() { + let d = dir("damaged"); + let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"]; + for bytes in cases { + std::fs::write(d.join(UI_FILE), bytes).unwrap(); + std::fs::write(d.join(PREFS_FILE), bytes).unwrap(); + let (ui, prefs) = load(&d); + // Prefs has no `zoom`, so that case is a valid (default) Prefs. + assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes)); + assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes)); + } + // A folder where a file should be, and an oversized file. + std::fs::remove_file(d.join(UI_FILE)).unwrap(); + std::fs::create_dir(d.join(UI_FILE)).unwrap(); + std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap(); + assert!(matches!(load(&d), (None, None))); + // Nothing there at all. + assert!(matches!(load(&d.join("missing")), (None, None))); + } + + #[test] + fn out_of_range_values_are_brought_into_range() { + let d = dir("ranges"); + let recent: Vec = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect(); + let ui = serde_json::json!({ + "zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30, + "thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500), + "recent": recent, + }); + let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)}); + std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap(); + std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap(); + let (ui, prefs) = load(&d); + let (ui, prefs) = (ui.unwrap(), prefs.unwrap()); + assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end())); + assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0)); + assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0); + assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None)); + assert_eq!(ui.recent.len(), crate::RECENT_MAX); + assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing); + assert_eq!(prefs.duplicate_offset, 1000.0); + assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT); + assert_eq!(prefs.recovery_minutes, 0.1); + assert_eq!(prefs.author.chars().count(), 256); + // A zero or negative zoom means "fit". + std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap(); + assert_eq!(load(&d).0.unwrap().zoom, None); + } + + #[test] + fn saving_round_trips_and_leaves_no_temporary_file() { + let d = dir("save"); + let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() }; + let prefs = Prefs { author: "Ann".into(), ..Default::default() }; + save(&d.join("nested"), &ui, &prefs); + let (u, p) = load(&d.join("nested")); + assert_eq!(u.unwrap().zoom, Some(1.5)); + assert_eq!(p.unwrap().author, "Ann"); + let names: Vec = + std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}"); + // A settings folder that is a file: nothing saved, no panic. + std::fs::write(d.join("blocked"), "file").unwrap(); + save(&d.join("blocked"), &ui, &prefs); + } + + /// The window opens with the worst `ui.json` values once they are sanitized; a zoom of + /// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up. + #[test] + fn the_window_opens_with_hostile_saved_state() { + let hostile = UiState { + zoom: Some(1.0e30), + notes_height: 1.0e30, + thumbs_width: -1.0e30, + pane: Some("format".into()), + pane_width: 1.0e30, + ..Default::default() + }; + for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default()); + // The second case is not sanitized: the canvas keeps a stray zoom in range itself. + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut crate::SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale); + } + } +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..a875773c 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1,10 +1,11 @@ //! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`), -//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings. +//! keyboard/mouse navigation, pen, black/white screens, See All Slides, zoom, presenter view +//! and rehearsed timings. -use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline}; +use deckcraft_anim::{Click, HitShape, Layer, LinkJump, ShowAction, ShowState, Source, StepStamp, Timeline, TriggerPlay}; use deckcraft_model::text::Hyperlink; use deckcraft_model::{Presentation, ShapeId}; -use deckcraft_render::{ParaState, RenderOpts, ShapeState}; +use deckcraft_render::{ParaState, PlacedLink, PlacedShape, RenderOpts, ShapeState}; use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; use serde_json::json; @@ -27,24 +28,48 @@ pub struct Show { trans: Option, tex: Option<(Key, TextureHandle)>, next_tex: Option<(usize, TextureHandle)>, + /// The next slide's first frame, rendered in the background while this one is shown so + /// advancing only uploads it (#240). + prefetch: Option, blank: Option, + /// See All Slides covers the show until a slide is chosen. + pub grid: bool, + /// The next click on the slide zooms in there. + pub zoom_arm: bool, + zoom: Option, + thumbs: Vec<(usize, TextureHandle)>, pen: bool, + /// A dot that follows the pointer and leaves no ink (`Cmd/Ctrl+L`). + laser: bool, strokes: Vec>, + /// Digits typed for Go to Slide, shown until Enter, Esc, or a short pause. goto: String, + goto_at: f64, started: bool, show_start: f64, + /// Elapsed time in presenter view. Pausing it does not pause the show. + timer: PresenterTimer, + /// Speaker-notes size in presenter view, in points. + notes_px: f32, timings: Vec<(usize, f64)>, last_move: f64, + /// The last trackpad flick or mouse-wheel notch, so one gesture turns one page. + last_swipe: f64, pub ended: bool, /// The slide whose media was last started (auto-play runs once per visit). media_slide: Option, /// The slide shown before the current one (the Last Slide Viewed action). last_viewed: Option, - /// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. - links: Option<(usize, Vec)>, + /// Click areas on slide `.0`, in slide points: hyperlinks and shape actions, and the slide's + /// shapes in drawing order. + links: Option<(usize, Vec, Vec)>, + /// The main step whose media effects are being set off, and how far into it they have been. + media_clock: Option<(usize, f64)>, + /// Media effects set off and not yet carried out: (shape, effect id). + media_cues: Vec<(ShapeId, String)>, } -#[derive(Clone, PartialEq)] +#[derive(Clone, Debug, PartialEq)] struct Key { slide: usize, step: usize, @@ -52,10 +77,82 @@ struct Key { doc: usize, /// Media hidden while not playing. hidden: Vec, + /// Clicks played of each triggered animation (what the shapes look like when none is running). + plays: Vec<(ShapeId, usize)>, /// The frames animated GIFs show. gif: u64, } +/// A slide frame being rendered on another thread: what it will be (`key`) and where it arrives. +struct Prefetch { + key: Key, + rx: std::sync::mpsc::Receiver, +} + +fn render_threads() -> u16 { + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } +} + +#[allow(clippy::too_many_arguments)] +/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. +fn render_frame( + doc: &Presentation, + idx: usize, + tl: Timeline, + step: usize, + t: f64, + size: (u32, u32), + hidden: Vec, + times: &[(ShapeId, f64)], + threads: u16, + plays: &[TriggerPlay], + stamps: &[StepStamp], +) -> egui::ColorImage { + let f = move |id: ShapeId| { + let mut st = state_for(&tl, id, step, t, plays, stamps); + if hidden.contains(&id) { + st.visible = false; + } + st + }; + let img = deckcraft_render::render_slide( + doc, + idx, + &RenderOpts { + scale: size.0 as f64 / doc.slide_size.width.max(1.0), + size: Some(size), + state: Some(&f), + gif_times: times, + threads, + ..Default::default() + }, + ); + crate::textures::to_color_image(&img, false) +} + +/// Media on slide `idx` hidden until it plays, as it is when the slide is entered. +fn hidden_on_entry(doc: &Presentation, idx: usize) -> Vec { + crate::media::slide_media(doc, idx).into_iter().filter(|(_, _, clip)| clip.hide_while_not_playing).map(|m| m.0).collect() +} + +/// Start rendering slide `next`'s first frame on another thread. `None` on the web (no threads). +fn start_prefetch(doc: &std::sync::Arc, next: usize, size: (u32, u32)) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let hidden = hidden_on_entry(doc, next); + let key = Key { slide: next, step: 0, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), gif: 0, plays: vec![] }; + let (tx, rx) = std::sync::mpsc::channel(); + let doc = doc.clone(); + // Leave a core for the UI thread. + let threads = render_threads().saturating_sub(1).max(1); + let spawned = std::thread::Builder::new().name("show-prefetch".into()).spawn(move || { + let tl = ShowState::timeline(&doc, next); + let _ = tx.send(render_frame(&doc, next, tl, 0, 0.0, size, hidden, &[], threads, &[], &[])); + }); + spawned.ok().map(|_| Prefetch { key, rx }) +} + struct Transition { old: Option, /// The slide left behind (Morph matches its shapes) and the Morph frame texture. @@ -87,21 +184,54 @@ impl Show { trans: None, tex: None, next_tex: None, + prefetch: None, blank: None, + grid: false, + zoom_arm: false, + zoom: None, + thumbs: vec![], pen: false, + laser: false, strokes: vec![], goto: String::new(), + goto_at: 0.0, started: false, show_start: 0.0, + timer: PresenterTimer::start(0.0), + notes_px: NOTES_DEFAULT, timings: vec![], last_move: 0.0, + last_swipe: f64::NEG_INFINITY, ended: false, media_slide: None, last_viewed: None, links: None, + media_clock: None, + media_cues: vec![], } } + /// Pen and laser are different pointers: turning one on puts the other away. + fn set_pen(&mut self, on: bool) { + self.pen = on; + if on { + self.laser = false; + } + } + + fn set_laser(&mut self, on: bool) { + self.laser = on; + if on { + self.pen = false; + } + } + + /// The arrow pointer: no ink and no laser dot. + fn arrow(&mut self) { + self.pen = false; + self.laser = false; + } + fn timeline(&mut self, doc: &Presentation) -> &Timeline { let i = self.state.slide; if self.timeline.as_ref().is_none_or(|(k, _)| *k != i) { @@ -118,13 +248,23 @@ impl Show { fn apply(&mut self, a: ShowAction, doc: &Presentation, now: f64) -> bool { match a { - ShowAction::PlayStep(_) => { + ShowAction::PlayStep(k) => { self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } - ShowAction::FinishStep(_) | ShowAction::StepBack(_) => { + ShowAction::FinishStep(n) => { + // A step cut short by a click still sets off the media it holds. + self.cue_media(doc, n, f64::INFINITY); + self.step_end = now; + } + ShowAction::StepBack(_) => { + self.media_clock = None; self.step_end = now; } ShowAction::Slide(i) => { + self.ended = false; + self.zoom = None; + self.zoom_arm = false; if self.rehearse { self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start)); } @@ -149,6 +289,7 @@ impl Show { self.slide_start = now; self.step_start = now; self.step_end = now; + self.media_clock = None; self.strokes.clear(); if self.preview_only { return false; @@ -168,19 +309,45 @@ impl Show { } /// On entering a slide: auto-play its first step when it begins With/After Previous. + /// `ShowState` already starts such a step when it enters the slide (#631: asking it for the + /// next step again finished the step at once, so the slide appeared fully built), so only + /// its clock starts here. Autoplay previews start an On Click first step themselves. fn after_enter(&mut self, doc: &Presentation, now: f64) { - let auto = self.timeline(doc).step_is_auto(0); - if auto || self.autoplay { - let a = self.state.next(doc); - if matches!(a, ShowAction::PlayStep(_)) { - self.step_start = now; + let started = if self.state.playing { + Some(self.state.step) + } else if self.autoplay { + match self.state.next(doc) { + ShowAction::PlayStep(k) => Some(k), + _ => None, } + } else { + None + }; + if let Some(k) = started { + self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } } + + /// Queue the media effects (Play, Pause, Stop) of main step `step` that the show reached since + /// the last call, up to `t` seconds into the step (#116). + fn cue_media(&mut self, doc: &Presentation, step: usize, t: f64) { + let Some((k, from)) = self.media_clock else { return }; + if k != step { + self.media_clock = None; + return; + } + let slide = self.state.slide; + let cues = self.timeline(doc).media_cues(step, from, t); + let effects: Vec<(ShapeId, String)> = + cues.iter().filter_map(|e| doc.slides.get(slide).and_then(|s| s.animations.get(e.anim)).map(|a| (e.shape, a.effect.clone()))).collect(); + self.media_cues.extend(effects); + self.media_clock = if t.is_finite() { Some((k, t)) } else { None }; + } } -fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { - let a = tl.state(id, None, step, t); +fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeState { + let a = tl.state_ordered(id, None, step, t, plays, stamps); ShapeState { visible: a.visible, opacity: a.opacity, @@ -201,13 +368,23 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { } fn fit(avail: Rect, doc: &Presentation) -> Rect { - let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); - let s = (avail.width() / sw).min(avail.height() / sh); + // Loaded sizes are repaired, but a zero side here would make every rectangle NaN. + let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 }; + let (sw, sh) = (side(doc.slide_size.width), side(doc.slide_size.height)); + let s = (avail.width() / sw).min(avail.height() / sh).max(0.0); Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) } /// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective /// cards subdivided so their pictures stay straight) with per-vertex opacity. +/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside +/// the top 60% of the column. A portrait slide used to get the full width and a height many +/// times the window (a white slab over the notes and past the window edge). +fn next_rect(right: Rect, doc: &Presentation) -> Rect { + let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6)); + Rect::from_min_size(area.min, fit(area, doc).size()) +} + fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { let mut m = match tex { Some(id) => Mesh::with_texture(id), @@ -233,7 +410,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; let tex = |i: usize, part: SlidePart, name: &str| { let img = deckcraft_render::render_slide_part(doc, i, part, &opts); - ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) }; [ from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), @@ -243,9 +420,107 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t ] } +/// Read the current viewport's show keys before its widgets can consume them. Both the +/// audience window and Presenter View use this path, including animation steps and end-of-show. +fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) -> bool { + let keys: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) + .collect() + }); + let mut keep = true; + let mut actions = vec![]; + for (k, m) in keys { + use egui::Key::*; + if show.grid && !matches!(k, Escape | Minus) { + // See All Slides takes no other keys until a slide is chosen. + if k == G && plain(m) { + show.grid = false; + } else { + continue; + } + } else { + match k { + Escape | Minus => { + let cancelled = k == Escape && show.clear_goto(); + if !cancelled && !show.back_out() { + keep = false; + } + } + ArrowRight | ArrowDown | Space | PageDown | N => { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + Enter => { + if !show.goto.is_empty() { + if let Some(i) = show.take_goto() + && i < doc.slides.len() + { + actions.push(show.state.goto(doc, i)); + } + } else if show.ended { + keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + Backspace if !show.goto.is_empty() => { + show.goto.pop(); + } + ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => { + show.ended = false; + actions.push(show.state.prev(doc)); + } + P if m.command => show.set_pen(!show.pen), + L if m.command => show.set_laser(!show.laser), + A if m.command => show.arrow(), + Home => { + if let Some(i) = show.state.first_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } + End => { + if let Some(i) = show.state.last_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } + B | Period => toggle_blank(show, Color32::BLACK), + W | Comma => toggle_blank(show, Color32::WHITE), + E => show.strokes.clear(), + G if plain(m) => show.grid = true, + Z if plain(m) => show.zoom_key(), + T if plain(m) => show.timer.toggle(now), + R if plain(m) => show.timer.reset(now), + OpenBracket if plain(m) => show.nudge_notes(-NOTES_STEP), + CloseBracket if plain(m) => show.nudge_notes(NOTES_STEP), + Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { + if let Some(d) = k.name().chars().next() { + show.push_slide_number(d, now); + } + } + _ => continue, + } + } + // The same key must not also activate a focused presenter button or be handled again + // by an embedded presenter viewport. + ctx.input_mut(|i| i.consume_key(m, k)); + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + for action in actions { + if !show.apply(action, doc, now) { + keep = false; + } + } + keep +} + pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { app.show = None; return; }; @@ -256,92 +531,109 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.slide_start = now; show.step_start = now; show.show_start = now; + show.timer = PresenterTimer::start(now); show.last_move = now; if !show.reading { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); } show.after_enter(&doc, now); } - let mut keep = true; - // Input. - let (keys, clicked, secondary, moved) = ctx.input(|i| { - let keys: Vec<(egui::Key, egui::Modifiers)> = i - .events - .iter() - .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) - .collect(); - (keys, i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0) - }); + let mut keep = keyboard(&ctx, &mut show, &doc, now); + let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); if moved { show.last_move = now; } let mut actions: Vec = vec![]; - for (k, m) in keys { - use egui::Key::*; - match k { - Escape | Minus => keep = false, - ArrowRight | ArrowDown | Space | PageDown | N => { - if show.ended { - keep = false; - } else { - actions.push(show.state.next(&doc)); - } - } - Enter => { - if let Ok(n) = show.goto.parse::() { - actions.push(show.state.goto(&doc, n.saturating_sub(1))); - show.goto.clear(); - } else if show.ended { - keep = false; - } else { - actions.push(show.state.next(&doc)); - } - } - ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => { - show.ended = false; - actions.push(show.state.prev(&doc)); - } - P if m.command => show.pen = !show.pen, - A if m.command => show.pen = false, - Home => actions.push(show.state.goto(&doc, 0)), - End => actions.push(show.state.goto(&doc, doc.slides.len().saturating_sub(1))), - B | Period => show.blank = if show.blank == Some(Color32::BLACK) { None } else { Some(Color32::BLACK) }, - W | Comma => show.blank = if show.blank == Some(Color32::WHITE) { None } else { Some(Color32::WHITE) }, - E => show.strokes.clear(), - Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { - show.goto.push_str(k.name()); - } - _ => {} - } - } - let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); - let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let pointer = ctx.input(|i| i.pointer.interact_pos()); + show.expire_goto(now); + // The clock of the triggered animations runs before anything reacts to the pointer. + show.tick_triggers(&doc, now); + let on_slide = !show.grid + && !show.pen + && !show.laser + && !show.ended + && show.blank.is_none() + && show.zoom.is_none() + && !show.zoom_arm + && !show.reading_bar_hit(&ctx) + && !pointer_on_media_bar(&ctx, ui, &doc, &show); + let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; - let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None }; + let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; + let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { - app.media.toggle(&doc, id, &clip, crate::media::Owner::Show); - } else if clicked && let Some(l) = &link { + app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); + } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { let last = show.last_viewed; - match show.state.follow(&doc, &l.action, last) { + match show.state.follow_link(&doc, &l, last) { LinkJump::Show(a) => actions.push(a), LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), LinkJump::Exit => keep = false, LinkJump::None => {} } - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) { + } else if show.grid && clicked { + let aspect = slide_aspect(&doc); + if let Some(p) = pointer + && let Some(n) = see_all_hit(full, grid_indices(doc.slides.len()).len(), aspect, p) + && let Some(&i) = grid_indices(doc.slides.len()).get(n) + { + show.grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + actions.push(show.state.goto(&doc, i)); + } + } else if clicked && show.zoom_arm && !show.reading_bar_hit(&ctx) { + if let Some(p) = pointer + && let Some((nx, ny)) = norm_on_slide(srect, unit_uv(), p) + { + show.zoom = Some(Zoom::at(nx, ny)); + show.zoom_arm = false; + } + } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { + show.dismiss_zoom(); + } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) { if show.ended { keep = false; } else if show.blank.is_some() { show.blank = None; } else { - actions.push(show.state.next(&doc)); + // A shape with an action or a triggered animation, or the background. + let none = Under::default(); + match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { + Click::Show(a) => actions.push(a), + Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Click::Exit => keep = false, + Click::Animated | Click::None => {} + } } } - if secondary && !show.ended { + if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { actions.push(show.state.prev(&doc)); } + // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick + // there stays on the slide. Pinch zoom is not a show gesture. + if !show.pen + && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) + && now - show.last_swipe > 0.35 + { + show.last_swipe = now; + if next { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } else { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + } // Apply input first: a step started by this click must restart the clock before it is read. for a in actions.drain(..) { if !show.apply(a, &doc, now) { @@ -364,7 +656,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let next_auto = show.timeline(&doc).step_is_auto(cur_step); let n_steps = show.timeline(&doc).steps(); let step_due = cur_step < n_steps && ((show.autoplay && idle > 0.3) || next_auto); - if step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended) { + let holding = show.grid || show.zoom.is_some() || show.zoom_arm; + if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) { actions.push(show.state.next(&doc)); } else if show.preview_only && cur_step >= n_steps && idle > 0.8 { keep = false; @@ -375,15 +668,22 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } - show_media(app, &mut show, &doc); + if show.state.playing { + let step = show.state.step; + show.cue_media(&doc, step, now - show.step_start); + } + show_media(app, &mut show, doc_uid, &doc); + run_media_cues(app, &mut show, doc_uid, &doc); // Draw. - let full = ui.max_rect(); let painter = ui.painter_at(full); painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); let ppp = ctx.pixels_per_point(); - let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32); + // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit + // (drawn stretched to the screen). + let size = crate::textures::fit_size( + ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), + crate::textures::max_side(&ctx), + ); if show.ended { painter.text( pos2(full.center().x, full.min.y + 40.0), @@ -395,8 +695,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } else { let idx = show.state.slide; let step = show.state.step; - let animating = show.state.playing; - let t = if animating { now - show.step_start } else { 0.0 }; + // A main step or a triggered animation is running: the picture changes every frame. + let animating = show.state.playing || show.state.trigger_playing(); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let plays = show.state.trigger_plays(); + let stamps = show.state.trigger_stamps(); let media = crate::media::slide_media(&doc, idx); let hidden: Vec = media .iter() @@ -405,48 +708,48 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { }) .map(|m| m.0) .collect(); - let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused); + let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); crate::media::gif_repaint(&ctx, &gifs); let gif = crate::media::gif_key(&gifs); - let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif }; + let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { show.tex.as_ref().map(|(_, t)| t.clone()) } else { - let tl = show.timeline(&doc).clone(); - let hide = hidden.clone(); - let f = move |id: ShapeId| { - let mut st = state_for(&tl, id, step, t); - if hide.contains(&id) { - st.visible = false; + // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). + let ready = match show.prefetch.take() { + Some(p) if p.key == key && !animating => p.rx.recv().ok(), + _ => None, + }; + let ci = match ready { + Some(ci) => ci, + None => { + let tl = show.timeline(&doc).clone(); + render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps) } - st }; - let threads = - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let img = deckcraft_render::render_slide( - &doc, - idx, - &RenderOpts { - scale: size.0 as f64 / doc.slide_size.width.max(1.0), - size: Some(size), - state: Some(&f), - gif_times: ×, - threads, - ..Default::default() - }, - ); - let ci = crate::textures::to_color_image(&img, false); let tex = match show.tex.take() { Some((_, mut h)) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "show", ci), }; show.tex = Some((key, tex.clone())); Some(tex) }; + // While this slide rests, render the next one's first frame (not one with GIFs, whose + // frame depends on when it is entered). + if !animating && show.trans.is_none() && !show.preview_only { + let next = show.state.peek_next(&doc); + let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); + if let Some(n) = wanted + && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) + { + show.prefetch = start_prefetch(&doc, n, size); + } + } let mut drew = false; let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; @@ -454,7 +757,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { (Some(tr), Some(tl)) => { let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); let hidden_b = |id: ShapeId| { - let st = state_for(&tl, id, 0, 0.0); + let st = state_for(&tl, id, 0, 0.0, &[], &[]); !st.visible || st.opacity <= 0.0 || hidden.contains(&id) }; tr.from @@ -484,10 +787,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let frame = match tr.morph.take() { Some(mut h) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "morph", ci), }; painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); tr.morph = Some(frame); @@ -534,10 +837,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if !drew { show.trans = None; if let Some(tex) = &tex { - painter.image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); } // Playing video: the current frame in its box, or full screen. - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); + let uv = show.zoom_uv(); for (id, x, clip) in &media { if !clip.video || hidden.contains(id) { continue; @@ -545,17 +849,37 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let r = if clip.full_screen && app.media.is_playing(*id) { full } else { - Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)) + slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) }; crate::media::paint_frame(&painter, &app.media, *id, r); } - // Play/pause on the animated GIF under the pointer. - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && now - show.last_move < 2.5) { - for g in &gifs { - let r = on_screen(srect, k, &g.rect); - if r.contains(p) { - let b = crate::media::gif_button_rect(r); - crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); + // Play/pause on the animated GIF under the pointer, and the audio/video bar. + // Slide Show ▸ Show Media Controls hides both (the click still plays a clip). + // Not while zoomed: the controls sit where the whole slide would be. + if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { + let k = srect.width() / sw.max(1.0); + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) { + for g in &gifs { + let r = on_screen(srect, k, &g.rect); + if r.contains(p) { + let b = crate::media::gif_button_rect(r); + crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); + } + } + } + if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) { + let action = { + let status = app.media.status(id); + crate::media::control_bar(ui, bar, status.as_ref(), &clip) + }; + let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); + match action { + Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show), + Some(crate::media::BarAction::Nudge(dt)) => { + app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show) + } + None => {} } } } @@ -567,29 +891,62 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if let Some(c) = show.blank { painter.rect_filled(full, CornerRadius::ZERO, c); } + if !show.goto.is_empty() { + let label = format!("Go to slide {}", show.goto); + let w = (28.0 + label.len() as f32 * 10.0).clamp(160.0, full.width() - 32.0); + let r = Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 96.0), vec2(w, 36.0)); + painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(190)); + painter.text(r.center(), Align2::CENTER_CENTER, label, theme::font(18.0), Color32::WHITE); + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } // Pen. let resp = ui.interact(full, ui.id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); if show.pen { if resp.drag_started() { show.strokes.push(vec![]); } + let uv = show.zoom_uv(); if resp.dragged() - && let (Some(p), Some(s)) = (resp.interact_pointer_pos(), show.strokes.last_mut()) + && let Some(p) = resp.interact_pointer_pos() + && let Some((nx, ny)) = norm_on_slide(srect, uv, p) + && let Some(s) = show.strokes.last_mut() { - s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height())); + s.push(pos2(nx, ny)); } ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if link.is_some() { + } else if show.laser { + ctx.set_cursor_icon(egui::CursorIcon::None); + } else if show.zoom_arm || show.grid { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if pointing { ctx.set_cursor_icon(egui::CursorIcon::PointingHand); } else if now - show.last_move > 2.5 && !show.reading { ctx.set_cursor_icon(egui::CursorIcon::None); ctx.request_repaint_after(std::time::Duration::from_millis(500)); } let pen_col = theme::to_color32(doc.show.pen_color); + let uv = show.zoom_uv(); for s in &show.strokes { - let pts: Vec = s.iter().map(|q| pos2(srect.min.x + q.x * srect.width(), srect.min.y + q.y * srect.height())).collect(); + let pts: Vec = s.iter().map(|q| slide_to_screen(srect, uv, q.x, q.y)).collect(); painter.add(egui::Shape::line(pts, Stroke::new(3.0, pen_col))); } + if show.laser + && !show.ended + && let Some(p) = ctx.input(|i| i.pointer.hover_pos()) + { + let c = theme::to_color32(doc.show.laser_color); + painter.circle_filled(p, 16.0, Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 70)); + painter.circle_filled(p, 5.5, c); + } + if show.zoom_arm && !show.grid { + painter.text( + pos2(full.center().x, full.min.y + 36.0), + Align2::CENTER_CENTER, + "Click the slide to zoom in. Esc cancels.", + theme::font(16.0), + Color32::WHITE, + ); + } // Reading view bar / on-screen controls. if show.reading { let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); @@ -619,8 +976,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let a = show.state.prev(&doc); show.apply(a, &doc, now); } - } else if now - show.last_move < 2.5 { - let bar = Rect::from_min_size(pos2(full.min.x + 20.0, full.max.y - 50.0), vec2(250.0, 34.0)); + } else if !show.grid && (now - show.last_move < 2.5 || show.zoom_arm) { + let bar = tool_bar_rect(full); painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); @@ -636,16 +993,31 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { - show.pen = !show.pen; + show.set_pen(!show.pen); } - if b(&mut child, "⬛").clicked() { - show.blank = Some(Color32::BLACK); + if b(&mut child, if show.laser { "◉ on" } else { "◉" }).clicked() { + show.set_laser(!show.laser); + } + if b(&mut child, "Black").clicked() { + toggle_blank(&mut show, Color32::BLACK); + } + if b(&mut child, "White").clicked() { + toggle_blank(&mut show, Color32::WHITE); + } + if b(&mut child, "All").clicked() { + show.grid = !show.grid; + } + if b(&mut child, show.zoom_label()).clicked() { + show.zoom_key(); } if b(&mut child, "End").clicked() { keep = false; } ctx.request_repaint_after(std::time::Duration::from_millis(300)); } + if show.grid && !show.ended { + paint_grid(&painter, &ctx, &mut show, &doc, full); + } if show.rehearse { let t = now - show.slide_start; let total = now - show.show_start; @@ -661,7 +1033,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // Presenter view in a second window. - if show.presenter && !show.reading && !show.ended { + if keep && show.presenter && !show.reading { presenter_window(&ctx, &mut show, &doc, now, &mut keep); } if keep { @@ -686,16 +1058,31 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } +/// The media-host voice for slide transition sounds (no shape has this id). +pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); + /// Start the media of a newly shown slide: media from the previous slide stops unless it plays /// across slides; media set to start automatically plays. Everything stops at the end. -fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { +fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { let cur = if show.ended { None } else { Some(show.state.slide) }; if show.media_slide == cur { return; } show.media_slide = cur; + let trans = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()); + let sound = trans.and_then(|t| t.sound); + let sound_loop = trans.is_some_and(|t| t.sound_loop); + // [Stop Previous Sound] ends a transition sound still playing. + let stop_previous = trans.is_some_and(|t| t.stop_sound); let keep: Vec = match cur { - Some(_) => app.media.ids().into_iter().filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)).collect(), + Some(_) => app + .media + .ids() + .into_iter() + .filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)) + // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. + .chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) + .collect(), None => vec![], }; for id in app.media.ids() { @@ -703,15 +1090,89 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { app.media.stop(id); } } + if let Some(media) = sound { + // Loop Until Next Sound: it repeats until another slide's sound or Stop Previous Sound. + let clip = deckcraft_model::MediaClip { media, volume: 1.0, loop_play: sound_loop, ..Default::default() }; + app.media.play(doc_uid, doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); + } if let Some(i) = cur { for (id, _, clip) in crate::media::slide_media(doc, i) { if clip.autoplay && !keep.contains(&id) { - app.media.play(doc, id, &clip, None, crate::media::Owner::Show); + app.media.play(doc_uid, doc, id, &clip, None, crate::media::Owner::Show); } } } } +/// Carry out the media effects the show's steps set off: Play starts from the beginning unless +/// the clip is already playing, Pause toggles, Stop stops. +fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { + for (id, effect) in std::mem::take(&mut show.media_cues) { + let Some(clip) = crate::media::find_clip(doc, id) else { continue }; + match effect.as_str() { + "pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), + "stop" => app.media.stop(id), + // Already playing (it also starts automatically): leave it running. + _ if app.media.is_playing(id) => {} + _ => app.media.play(doc_uid, doc, id, &clip, Some(0.0), crate::media::Owner::Show), + } + } +} + +/// Slide rectangle the show draws into (the reading bar keeps its strip). +fn shown_slide_rect(ui: &Ui, show: &Show, doc: &Presentation) -> Rect { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + fit(avail, doc) +} + +/// Playback bar for one clip: along the bottom inside a large shape, just under a small icon. +fn media_bar_rect(shape: Rect) -> Rect { + let h = 26.0f32; + let w = shape.width().clamp(160.0, 420.0); + if shape.height() >= h + 16.0 && shape.width() >= 160.0 { + Rect::from_min_size(pos2(shape.min.x + 4.0, shape.max.y - h - 4.0), vec2((shape.width() - 8.0).min(w), h)) + } else { + Rect::from_min_size(pos2(shape.center().x - w / 2.0, shape.max.y + 4.0), vec2(w, h)) + } +} + +fn slide_media_bars(srect: Rect, doc: &Presentation, slide: usize) -> Vec<(ShapeId, deckcraft_model::MediaClip, Rect, Rect)> { + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + crate::media::slide_media(doc, slide) + .into_iter() + .map(|(id, x, clip)| { + let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)); + let bar = media_bar_rect(r); + (id, clip, r, bar) + }) + .collect() +} + +/// The clip whose bar or shape is under the pointer, when Show Media Controls is on. +fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect: Rect) -> Option<(ShapeId, deckcraft_model::MediaClip, Rect)> { + if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { + return None; + } + let p = ctx.input(|i| i.pointer.hover_pos())?; + let items = slide_media_bars(srect, doc, show.state.slide); + if let Some((id, clip, _, bar)) = items.iter().rev().find(|(_, _, _, bar)| bar.contains(p)) { + return Some((*id, clip.clone(), *bar)); + } + let (id, clip, _, bar) = items.iter().rev().find(|(_, _, r, _)| r.contains(p))?; + Some((*id, clip.clone(), *bar)) +} + +/// True when the pointer is on a visible playback bar, so that click must not advance the show. +fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool { + if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { + return false; + } + let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false }; + let srect = shown_slide_rect(ui, show, doc); + slide_media_bars(srect, doc, show.state.slide).iter().any(|(_, _, _, bar)| bar.contains(p)) +} + /// The media shape under the pointer on the shown slide. fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> Option<(ShapeId, deckcraft_model::MediaClip)> { let p = ctx.input(|i| i.pointer.interact_pos())?; @@ -725,9 +1186,34 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O }) } -/// The link under the pointer: the topmost shape action or text hyperlink on the current slide -/// (a text link wins over its shape's action). -fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option { +/// What a click on the pointer's spot acts on. +#[derive(Clone)] +enum Pick { + /// A hyperlink in text. + Text(Hyperlink), + /// A shape with an action or a triggered animation. + Shape, +} + +/// The slide's shapes at the pointer, for [`ShowState::click`]. +#[derive(Default)] +struct Under { + shapes: Vec, + /// Whether each of `shapes` is currently shown (an animation may have hidden it). + visible: Vec, + pick: Option, +} + +impl Under { + fn visible(&self, id: ShapeId) -> bool { + self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) + } +} + +/// What is under the pointer on the shown slide: the slide's shapes with their animation state, the +/// topmost of them that does something when clicked, and a text hyperlink on it. Only shapes that +/// are shown can be clicked, and a shape without an action hides the ones beneath it. +fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation, now: f64) -> Option { let p = ctx.input(|i| i.pointer.hover_pos())?; let full = ui.max_rect(); let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; @@ -735,11 +1221,72 @@ fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) - let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); let i = show.state.slide; - if show.links.as_ref().is_none_or(|(s, _)| *s != i) { - show.links = Some((i, deckcraft_render::place_slide(doc, i).links)); + if show.links.as_ref().is_none_or(|(s, ..)| *s != i) { + let placed = deckcraft_render::place_slide(doc, i); + show.links = Some((i, placed.links, placed.shapes)); + } + show.timeline(doc); + let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; + let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; + let hits: Vec = shapes + .iter() + .map(|s| HitShape { + id: s.id, + parent: s.parent, + is_group: s.is_group, + link: s.link.as_ref().map(|l| l.action.clone()), + under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), + }) + .collect(); + let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let visible: Vec = shapes + .iter() + .map(|s| { + let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); + a.visible && a.opacity > 0.0 + }) + .collect(); + let mut under = Under { shapes: hits, visible, pick: None }; + let shown = |id: ShapeId| under.visible(id); + // A hyperlink in text applies when its shape is the one on top. + let top = show.state.top(doc, tl, &under.shapes, &shown); + let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { + let mut at = top; + (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { + Some(h) if h.id == l.shape => true, + Some(h) => { + at = h.parent; + false + } + None => false, + }) + }); + under.pick = match text { + Some(l) => Some(Pick::Text(l.link.clone())), + None => show.state.target(doc, tl, &under.shapes, &shown).map(|_| Pick::Shape), + }; + Some(under) +} + +impl Show { + /// Advance the clock of the running triggered animations. + fn tick_triggers(&mut self, doc: &Presentation, now: f64) { + self.timeline(doc); + if let Some((_, tl)) = &self.timeline { + self.state.tick(tl, now); + } + } + + /// A click on the slide: the shape under the pointer acts, or the background advances. + fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { + self.timeline(doc); + let last = self.last_viewed; + match &self.timeline { + Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), + None => Click::None, + } } - let (_, links) = show.links.as_ref()?; - links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone()) } /// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. @@ -748,79 +1295,517 @@ fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { } /// The animated GIF whose play/pause button is under the pointer on the shown slide. -fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option { +fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { let p = ctx.input(|i| i.pointer.interact_pos())?; let full = ui.max_rect(); let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; let srect = fit(avail, doc); let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); + let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) } impl Show { + fn zoom_uv(&self) -> Rect { + self.zoom.map(Zoom::uv).unwrap_or_else(unit_uv) + } + + fn zoom_label(&self) -> &'static str { + if self.zoom.is_some() { + "Zoom on" + } else if self.zoom_arm { + "Zoom…" + } else { + "Zoom" + } + } + + /// Z, or the Zoom button: arm a click-to-zoom, or leave a zoom that is already on. + fn zoom_key(&mut self) { + if self.zoom.is_some() || self.zoom_arm { + self.dismiss_zoom(); + } else { + self.zoom_arm = true; + self.pen = false; + } + } + + /// Esc closes See All Slides, then a zoom, and only then leaves the show. + fn back_out(&mut self) -> bool { + if self.grid { + self.grid = false; + true + } else { + self.dismiss_zoom() + } + } + + /// Esc backs out of a zoom before it leaves the show. True when it did. + fn dismiss_zoom(&mut self) -> bool { + if self.zoom.is_some() || self.zoom_arm { + self.zoom = None; + self.zoom_arm = false; + true + } else { + false + } + } + + pub fn blank_name(&self) -> Option<&'static str> { + match self.blank { + Some(c) if c == Color32::BLACK => Some("black"), + Some(c) if c == Color32::WHITE => Some("white"), + Some(_) => Some("blank"), + None => None, + } + } + + pub fn zoom_view(&self) -> Option<(f32, f32, f32)> { + self.zoom.map(|z| (z.nx, z.ny, z.scale)) + } + + fn push_slide_number(&mut self, digit: char, now: f64) { + if !digit.is_ascii_digit() || self.goto.len() >= GOTO_DIGITS { + return; + } + self.goto.push(digit); + self.goto_at = if now.is_finite() { now } else { 0.0 }; + } + + /// 1-based slide number, or `None` for 0 or a non-number. The buffer is cleared either way. + fn take_goto(&mut self) -> Option { + let raw = std::mem::take(&mut self.goto); + raw.parse::().ok().and_then(|n| n.checked_sub(1)) + } + + fn expire_goto(&mut self, now: f64) { + if self.goto.is_empty() || !now.is_finite() || !self.goto_at.is_finite() { + return; + } + if now - self.goto_at > GOTO_HOLD { + self.goto.clear(); + } + } + + /// Esc drops a number in progress. True when it did. + fn clear_goto(&mut self) -> bool { + if self.goto.is_empty() { + false + } else { + self.goto.clear(); + true + } + } + + fn nudge_notes(&mut self, delta: f32) { + let next = self.notes_px + delta; + if next.is_finite() { + self.notes_px = next.clamp(NOTES_MIN, NOTES_MAX); + } + } + + pub fn showing_value(&self, n_slides: usize, aspect: f32, full: Rect, now: f64) -> serde_json::Value { + let mut v = json!({ + "slide": self.state.slide, + "step": self.state.step, + "ended": self.ended, + "grid": self.grid, + "zoomArm": self.zoom_arm, + "blank": self.blank_name(), + "zoom": self.zoom_view().map(|(x, y, scale)| json!({"x": x, "y": y, "scale": scale})), + "presenterPaused": self.timer.paused(), + "presenterSecs": self.timer.elapsed(now), + "notesPx": self.notes_px, + "custom": self.state.custom_name(), + "goto": if self.goto.is_empty() { None } else { Some(self.goto.clone()) }, + }); + if self.grid { + let slides = grid_indices(n_slides); + let cells = see_all_cells(full, slides.len(), aspect); + v["cells"] = + serde_json::Value::Array(slides.iter().zip(cells).map(|(i, r)| json!({"slide": i, "x": r.center().x, "y": r.center().y})).collect()); + } + v + } + fn reading_bar_hit(&self, ctx: &egui::Context) -> bool { // Clicks on the on-screen controls must not also advance. ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| { let r = ctx.content_rect(); - (self.reading && p.y > r.max.y - 34.0) || (!self.reading && p.y > r.max.y - 56.0 && p.x < r.min.x + 280.0) + (self.reading && p.y > r.max.y - 34.0) || (!self.reading && tool_bar_rect(r).expand(8.0).contains(p)) }) } } +const GOTO_HOLD: f64 = 3.0; +const GOTO_DIGITS: usize = 4; +const ZOOM_SCALE: f32 = 2.0; +const GRID_CAP: usize = 48; +const TOOL_BAR_W: f32 = 520.0; +const NOTES_DEFAULT: f32 = 18.0; +const NOTES_MIN: f32 = 12.0; +const NOTES_MAX: f32 = 48.0; +const NOTES_STEP: f32 = 4.0; + +/// Presenter-view elapsed time. Pausing freezes the number; the show keeps playing. +#[derive(Clone, Copy)] +struct PresenterTimer { + origin: f64, + frozen: f64, + paused: bool, +} + +impl PresenterTimer { + fn start(now: f64) -> Self { + PresenterTimer { origin: finite_time(now), frozen: 0.0, paused: false } + } + + fn elapsed(self, now: f64) -> f64 { + if self.paused { + return finite_secs(self.frozen); + } + let dt = now - self.origin; + finite_secs(self.frozen + if dt.is_finite() && dt > 0.0 { dt } else { 0.0 }) + } + + fn paused(self) -> bool { + self.paused + } + + fn toggle(&mut self, now: f64) { + if self.paused { + self.origin = finite_time(now); + self.paused = false; + } else { + self.frozen = self.elapsed(now); + self.paused = true; + } + } + + /// Zero the elapsed time. A paused timer stays paused at zero. + fn reset(&mut self, now: f64) { + self.frozen = 0.0; + self.origin = finite_time(now); + } +} + +fn finite_time(now: f64) -> f64 { + if now.is_finite() { now } else { 0.0 } +} + +fn finite_secs(n: f64) -> f64 { + if n.is_finite() && n >= 0.0 { n } else { 0.0 } +} + +fn format_elapsed(secs: f64) -> String { + let s = finite_secs(secs) as u64; + let s = s.min(99 * 3600 + 59 * 60 + 59); + format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) +} + +/// Local wall clock for presenter view. `--:--` where there is no clock (wasm). +fn wall_clock() -> String { + match deckcraft_text::datetime::DateTime::now_local() { + Some(t) if t.hour < 24 && t.minute < 60 => format!("{:02}:{:02}", t.hour, t.minute), + _ => "--:--".to_string(), + } +} + +#[derive(Clone, Copy)] +struct Zoom { + nx: f32, + ny: f32, + scale: f32, +} + +impl Zoom { + fn at(nx: f32, ny: f32) -> Self { + Zoom { nx: finite_unit(nx), ny: finite_unit(ny), scale: ZOOM_SCALE } + } + + fn uv(self) -> Rect { + zoom_uv(self.nx, self.ny, self.scale) + } +} + +fn finite_unit(n: f32) -> f32 { + if n.is_finite() { n.clamp(0.0, 1.0) } else { 0.5 } +} + +fn plain(m: egui::Modifiers) -> bool { + !m.command && !m.ctrl && !m.alt && !m.shift +} + +fn unit_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +fn slide_aspect(doc: &Presentation) -> f32 { + let a = doc.slide_size.width as f32 / doc.slide_size.height.max(1.0) as f32; + if a.is_finite() && a > 0.05 { a } else { 16.0 / 9.0 } +} + +/// The part of the slide a zoom shows, in 0–1 texture space. The window stays on the slide. +fn zoom_uv(nx: f32, ny: f32, scale: f32) -> Rect { + let scale = if scale.is_finite() && scale > 1.0 { scale } else { ZOOM_SCALE }; + let w = 1.0 / scale; + if w >= 1.0 { + return unit_uv(); + } + let x = (nx - w * 0.5).clamp(0.0, 1.0 - w); + let y = (ny - w * 0.5).clamp(0.0, 1.0 - w); + Rect::from_min_size(pos2(x, y), vec2(w, w)) +} + +fn zoom_frame(slide: Rect, uv: Rect) -> Rect { + Rect::from_min_size( + pos2(slide.min.x + uv.min.x * slide.width(), slide.min.y + uv.min.y * slide.height()), + vec2(uv.width() * slide.width(), uv.height() * slide.height()), + ) +} + +fn toggle_blank(show: &mut Show, color: Color32) { + show.blank = if show.blank == Some(color) { None } else { Some(color) }; +} + +fn tool_bar_rect(full: Rect) -> Rect { + let w = TOOL_BAR_W.min((full.width() - 32.0).max(32.0)); + Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 50.0), vec2(w, 34.0)) +} + +fn grid_indices(n: usize) -> Vec { + (0..n.min(GRID_CAP)).collect() +} + +/// Thumbnail rectangles for `n` slides inside `full`, largest 16:9-style thumbs that fit. +fn see_all_cells(full: Rect, n: usize, aspect: f32) -> Vec { + if n == 0 || full.width() < 16.0 || full.height() < 16.0 { + return vec![]; + } + let aspect = if aspect.is_finite() && aspect > 0.05 { aspect } else { 16.0 / 9.0 }; + let margin = 28.0; + let gap = 16.0; + let label_h = 22.0; + let inner = full.shrink(margin); + let inner = Rect::from_min_max(pos2(inner.min.x, inner.min.y + 36.0), inner.max); + if inner.width() < 8.0 || inner.height() < 8.0 { + return vec![]; + } + let mut best: Vec = vec![]; + let mut best_area = 0.0_f32; + for cols in 1..=n.min(8) { + let rows = n.div_ceil(cols); + let cell_w = (inner.width() - gap * (cols.saturating_sub(1) as f32)) / cols as f32; + let cell_h = (inner.height() - gap * (rows.saturating_sub(1) as f32)) / rows as f32; + if cell_w < 8.0 || cell_h < label_h + 8.0 { + continue; + } + let thumb_max_h = cell_h - label_h; + let mut tw = cell_w; + let mut th = tw / aspect; + if th > thumb_max_h { + th = thumb_max_h; + tw = th * aspect; + } + if tw < 8.0 || th < 8.0 { + continue; + } + let area = tw * th; + if area <= best_area { + continue; + } + best_area = area; + best = (0..n) + .map(|i| { + let c = i % cols; + let r = i / cols; + let ox = inner.min.x + c as f32 * (cell_w + gap) + (cell_w - tw) * 0.5; + let oy = inner.min.y + r as f32 * (cell_h + gap); + Rect::from_min_size(pos2(ox, oy), vec2(tw, th)) + }) + .collect(); + } + best +} + +fn see_all_hit(full: Rect, n: usize, aspect: f32, p: Pos2) -> Option { + see_all_cells(full, n, aspect).into_iter().position(|r| r.expand2(vec2(4.0, 18.0)).contains(p)) +} + +fn norm_on_slide(srect: Rect, uv: Rect, p: Pos2) -> Option<(f32, f32)> { + if srect.width() <= 1.0 || srect.height() <= 1.0 || !srect.contains(p) { + return None; + } + let u = (p.x - srect.min.x) / srect.width(); + let v = (p.y - srect.min.y) / srect.height(); + let nx = uv.min.x + u * uv.width(); + let ny = uv.min.y + v * uv.height(); + (nx.is_finite() && ny.is_finite()).then_some((nx.clamp(0.0, 1.0), ny.clamp(0.0, 1.0))) +} + +fn slide_to_screen(srect: Rect, uv: Rect, nx: f32, ny: f32) -> Pos2 { + let u = (nx - uv.min.x) / uv.width().max(1e-4); + let v = (ny - uv.min.y) / uv.height().max(1e-4); + pos2(srect.min.x + u * srect.width(), srect.min.y + v * srect.height()) +} + +fn slide_box(srect: Rect, uv: Rect, slide_w: f32, slide_h: f32, x: f32, y: f32, w: f32, h: f32) -> Rect { + let a = slide_to_screen(srect, uv, x / slide_w.max(1.0), y / slide_h.max(1.0)); + let b = slide_to_screen(srect, uv, (x + w) / slide_w.max(1.0), (y + h) / slide_h.max(1.0)); + Rect::from_min_max(a, b) +} + +fn paint_grid(painter: &egui::Painter, ctx: &egui::Context, show: &mut Show, doc: &Presentation, full: Rect) { + let slides = grid_indices(doc.slides.len()); + let aspect = slide_aspect(doc); + let cells = see_all_cells(full, slides.len(), aspect); + painter.rect_filled(full, CornerRadius::ZERO, Color32::from_gray(16)); + painter.text(pos2(full.center().x, full.min.y + 28.0), Align2::CENTER_CENTER, "All slides", theme::font(20.0), Color32::WHITE); + if doc.slides.len() > GRID_CAP { + painter.text( + pos2(full.center().x, full.min.y + 50.0), + Align2::CENTER_CENTER, + format!("First {GRID_CAP} slides"), + theme::font(13.0), + Color32::from_gray(180), + ); + } + let budget = if slides.len() <= 12 { 12 } else { 4 }; + let mut made = 0; + for (i, rect) in slides.iter().copied().zip(cells) { + painter.rect_filled(rect, CornerRadius::same(4), Color32::from_gray(32)); + let tex = match show.thumbs.iter().find(|(k, _)| *k == i).map(|(_, t)| t.clone()) { + Some(t) => Some(t), + None if made < budget => { + made += 1; + render_thumb(ctx, show, doc, i) + } + None => { + ctx.request_repaint(); + None + } + }; + if let Some(t) = tex { + painter.image(t.id(), rect, unit_uv(), Color32::WHITE); + } + let current = i == show.state.slide; + painter.rect_stroke( + rect, + CornerRadius::same(4), + Stroke::new(if current { 3.0 } else { 1.0 }, if current { Color32::WHITE } else { Color32::from_gray(70) }), + egui::StrokeKind::Inside, + ); + let hidden = doc.slides.get(i).is_some_and(|s| s.hidden); + let label = if hidden { format!("{} Hidden", i + 1) } else { format!("{}", i + 1) }; + painter.text( + pos2(rect.center().x, rect.max.y + 12.0), + Align2::CENTER_CENTER, + label, + theme::font(13.0), + if current { Color32::WHITE } else { Color32::from_gray(180) }, + ); + } +} + +fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option { + if show.thumbs.len() >= GRID_CAP { + return None; + } + let w = 320.0_f64; + let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0); + let img = deckcraft_render::render_slide( + doc, + i, + &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, + ); + let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); + show.thumbs.push((i, tex.clone())); + Some(tex) +} + fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { let cur = show.state.slide; - let next = deckcraft_anim::show_order(doc).into_iter().find(|i| *i > cur); + let next = show.state.peek_next(doc); let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); if let Some(n) = next && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) { + // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. + let next_size = crate::textures::fit_size( + (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_slide( doc, n, - &RenderOpts { - scale: 480.0 / doc.slide_size.width.max(1.0), - size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), - ..Default::default() - }, + &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, ); - show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))); + show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); } let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); - let elapsed = now - show.show_start; + let clock = wall_clock(); + // The presenter clock only moves when this window is drawn again. + ctx.request_repaint_after(std::time::Duration::from_millis(500)); let mut actions = vec![]; + // The presenter window is dark; default buttons draw dark text on it (#221). + let button = |ui: &mut Ui, s: &str| { + ui.add(egui::Button::new(egui::RichText::new(s).size(15.0).color(Color32::WHITE)).fill(Color32::from_gray(60)).min_size(vec2(0.0, 30.0))) + }; ctx.show_viewport_immediate( egui::ViewportId::from_hash_of("deckcraft_presenter"), egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), |ui, _| { + if !keyboard(ui.ctx(), show, doc, now) { + *keep = false; + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + let elapsed = show.timer.elapsed(now); + let paused = show.timer.paused(); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!( - "{:02}:{:02}:{:02}", - (elapsed / 3600.0) as u32, - (elapsed / 60.0) as u32 % 60, - elapsed as u32 % 60 - )) - .size(22.0) - .color(Color32::WHITE), - ); + ui.label(egui::RichText::new(format_elapsed(elapsed)).size(22.0).color(if paused { + Color32::from_gray(160) + } else { + Color32::WHITE + })); + ui.add_space(12.0); + ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200))); ui.add_space(20.0); ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("End Show").clicked() { + if button(ui, "End Show").clicked() { *keep = false; } - if ui.button("Next ▶").clicked() { + if button(ui, "Next ▶").clicked() { actions.push(show.state.next(doc)); } - if ui.button("◀ Previous").clicked() { + if button(ui, "◀ Previous").clicked() { actions.push(show.state.prev(doc)); } }); }); - ui.add_space(10.0); + ui.horizontal(|ui| { + if button(ui, if paused { "Resume" } else { "Pause" }).clicked() { + show.timer.toggle(now); + } + if button(ui, "Reset").clicked() { + show.timer.reset(now); + } + if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { + toggle_blank(show, Color32::BLACK); + } + if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { + toggle_blank(show, Color32::WHITE); + } + if button(ui, if show.grid { "All slides on" } else { "All slides" }).clicked() { + show.grid = !show.grid; + } + if button(ui, show.zoom_label()).clicked() { + show.zoom_key(); + } + }); + ui.add_space(8.0); let avail = ui.available_rect_before_wrap(); let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y)); let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max); @@ -829,10 +1814,25 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(t) = &cur_tex { ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - let nr = Rect::from_min_size( - right.min + vec2(0.0, 22.0), - vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32), - ); + if let Some(z) = show.zoom { + let mark = zoom_frame(cr, z.uv()); + ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside); + } + if show.zoom_arm { + let hit = ui.interact(cr, ui.id().with("presenter_zoom"), Sense::click()); + if hit.clicked() + && let Some(p) = hit.interact_pointer_pos() + && cr.width() > 1.0 + && cr.height() > 1.0 + { + show.zoom = Some(Zoom::at((p.x - cr.min.x) / cr.width(), (p.y - cr.min.y) / cr.height())); + show.zoom_arm = false; + } + } + if let Some(c) = show.blank { + ui.painter().rect_filled(cr, CornerRadius::ZERO, c); + } + let nr = next_rect(right, doc); ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); match &next_tex { Some(t) => { @@ -843,10 +1843,24 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); } } - let notes_rect = Rect::from_min_max(pos2(right.min.x, nr.max.y + 16.0), right.max); + let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); + let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); + head.horizontal(|ui| { + ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170))); + if ui.button("A-").clicked() { + show.nudge_notes(-NOTES_STEP); + } + ui.label(egui::RichText::new(format!("{}", show.notes_px as i32)).size(13.0).color(Color32::from_gray(200))); + if ui.button("A+").clicked() { + show.nudge_notes(NOTES_STEP); + } + }); + let notes_rect = Rect::from_min_max(pos2(right.min.x, notes_head.max.y + 4.0), right.max); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); egui::ScrollArea::vertical().show(&mut child, |ui| { - ui.label(egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(18.0).color(Color32::from_gray(230))); + ui.label( + egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(show.notes_px).color(Color32::from_gray(230)), + ); }); }); if ui.input(|i| i.viewport().close_requested()) { @@ -867,6 +1881,111 @@ mod tests { use super::*; + /// Send real egui key events to the presenter UI, without the audience window's handler. + #[test] + fn presenter_navigation_uses_its_own_keyboard_events() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.run_steps(2); + for (key, slide) in [ + (egui::Key::ArrowRight, 1), + (egui::Key::ArrowLeft, 0), + (egui::Key::Space, 1), + (egui::Key::PageDown, 2), + (egui::Key::PageUp, 1), + (egui::Key::Home, 0), + (egui::Key::End, 2), + ] { + harness.key_press(key); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, slide, "presenter key {key:?}"); + } + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.key_press(egui::Key::ArrowLeft); + harness.run_steps(2); + assert!(!harness.state().1.ended); + assert_eq!(harness.state().1.state.slide, 2, "back from the end screen restores the last slide"); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.get_by_label("◀ Previous").click(); + harness.run_steps(2); + assert!(!harness.state().1.ended, "the presenter Previous button also leaves the end screen"); + harness.key_press(egui::Key::Home); + harness.run_steps(2); + harness.get_by_label("Next ▶").click(); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 1); + harness.key_press(egui::Key::Space); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 2); + assert!(!harness.state().1.ended, "Space must not also activate the focused Next button"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().2, "Escape from Presenter View ends the show"); + } + + #[test] + fn slideshow_navigation_advances_once_with_presenter_view() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = true; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1, "one key advances one slide"); + harness.key_press(egui::Key::End); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().ended); + assert!(harness.query_by_label("End Show").is_some(), "the presenter remains available at the end screen"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().show.is_none()); + } + + #[test] + fn the_prefetched_frame_is_the_frame_shown_on_entry() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({"title": "Second"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let size = (96, 54); + let p = start_prefetch(&doc, 1, size).expect("prefetch starts"); + assert_eq!(p.key, Key { slide: 1, step: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: vec![], gif: 0, plays: vec![] }); + let got = p.rx.recv().expect("the frame arrives"); + let want = render_frame(&doc, 1, ShowState::timeline(&doc, 1), 0, 0.0, size, vec![], &[], 0, &[], &[]); + assert_eq!(got.size, want.size); + assert_eq!(got.pixels, want.pixels); + } + #[test] fn a_transition_keeps_the_old_slide_in_its_own_texture() { // #33: the old slide's handle was cloned, so the new slide was rendered into the same @@ -876,13 +1995,255 @@ mod tests { s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); let doc = s.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); + let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); let mut show = Show::new(&doc, 0, false, false); - show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone())); + show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone())); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); let tr = show.trans.as_ref().expect("the fade plays"); assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id())); assert_eq!(tr.from, Some(0)); assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); } + + #[test] + fn media_in_the_click_sequence_plays_with_its_step() { + // #116: media set to play in the click sequence never started; only a click on it did. + let s = deckcraft_engine::Session::with_new(); + let mut doc = s.active().unwrap().doc.clone(); + let id = doc.slides[0].shapes[0].id; + let play = |delay_ms| deckcraft_model::Animation { + shape: id, + class: deckcraft_model::AnimClass::Media, + effect: "play".into(), + duration_ms: 5000, + delay_ms, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut doc).slides[0]).animations = vec![play(0), play(2000)]; + let mut show = Show::new(&doc, 0, false, false); + assert!(show.apply(ShowAction::Slide(0), &doc, 0.0)); + // Next: the first step plays and sets off its Play once. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(0)); + assert!(show.apply(a, &doc, 1.0)); + show.cue_media(&doc, 0, 0.0); + show.cue_media(&doc, 0, 0.5); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // The step doesn't wait for the video: the step clock ends it at once. + assert_eq!(show.timeline(&doc).step_duration(0), 0.0); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 1.0)); + assert!(show.media_cues.is_empty(), "set off once"); + // Next again, before the second Play's delay: cutting the step short still sets it off. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(1)); + assert!(show.apply(a, &doc, 2.0)); + show.cue_media(&doc, 1, 0.5); + assert!(show.media_cues.is_empty()); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 2.6)); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // Going back doesn't set anything off. + let a = show.state.prev(&doc); + assert!(show.apply(a, &doc, 3.0)); + show.cue_media(&doc, 1, 9.0); + assert!(show.media_cues.is_empty()); + } + + #[test] + fn the_laser_and_the_pen_are_not_on_together() { + let doc = deckcraft_model::Presentation::default(); + let mut show = Show::new(&doc, 0, false, false); + show.set_laser(true); + assert!(show.laser && !show.pen); + show.set_pen(true); + assert!(show.pen && !show.laser); + show.set_laser(true); + show.arrow(); + assert!(!show.laser && !show.pen); + show.set_laser(false); + assert!(!show.laser); + } + + fn deck_with_first_animation(start: deckcraft_model::anim::AnimStart) -> Presentation { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut doc = Presentation::clone(&s.active().unwrap().doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[1]); + let shape = slide.shapes[0].id; + slide.animations = vec![deckcraft_model::anim::Animation { shape, start, duration_ms: 3000, ..Default::default() }]; + doc + } + + #[test] + fn a_first_animation_after_or_with_previous_plays_when_the_slide_appears() { + // #631: it was started twice and the second start finished it, so the slide appeared + // fully built. + use deckcraft_model::anim::AnimStart; + for start in [AnimStart::AfterPrevious, AnimStart::WithPrevious] { + let doc = deck_with_first_animation(start); + // Starting the show on the slide. + let mut show = Show::new(&doc, 1, false, false); + show.after_enter(&doc, 5.0); + assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} from the start"); + assert_eq!(show.step_start, 5.0); + // Arriving from the slide before. + let mut show = Show::new(&doc, 0, false, false); + let a = show.state.next(&doc); + assert!(matches!(a, ShowAction::Slide(1))); + assert!(show.apply(a, &doc, 7.0)); + assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} on arrival"); + } + // On Click still waits for the click. + let doc = deck_with_first_animation(AnimStart::OnClick); + let mut show = Show::new(&doc, 1, false, false); + show.after_enter(&doc, 5.0); + assert_eq!((show.state.step, show.state.playing), (0, false)); + assert!(matches!(show.state.next(&doc), ShowAction::PlayStep(0))); + } + + #[test] + fn zoom_stays_on_the_slide_and_the_grid_hits_a_thumbnail() { + let uv = zoom_uv(0.5, 0.5, 2.0); + assert!((uv.min.x - 0.25).abs() < 1e-4 && (uv.min.y - 0.25).abs() < 1e-4); + assert!((uv.width() - 0.5).abs() < 1e-4); + let corner = zoom_uv(0.0, 0.0, 2.0); + assert_eq!(corner.min, pos2(0.0, 0.0)); + assert!(corner.max.x <= 1.0 && corner.max.y <= 1.0); + let full = Rect::from_min_size(Pos2::ZERO, vec2(1728.0, 1084.0)); + let cells = see_all_cells(full, 7, 16.0 / 9.0); + assert_eq!(cells.len(), 7); + for (i, a) in cells.iter().enumerate() { + assert!(full.contains_rect(*a), "cell {i} leaves the window"); + for b in cells.iter().skip(i + 1) { + let overlap = a.intersect(*b); + assert!(overlap.width() < 1.0 || overlap.height() < 1.0, "thumbnails overlap {a:?} {b:?}"); + } + } + let hit = see_all_hit(full, 7, 16.0 / 9.0, cells[3].center()); + assert_eq!(hit, Some(3)); + assert_eq!(see_all_hit(full, 7, 16.0 / 9.0, pos2(2.0, 2.0)), None); + let srect = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 100.0)); + assert_eq!(norm_on_slide(srect, unit_uv(), pos2(110.0, 70.0)), Some((0.5, 0.5))); + let zoomed = zoom_uv(0.5, 0.5, 2.0); + let back = norm_on_slide(srect, zoomed, pos2(110.0, 70.0)).expect("on the slide"); + assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3); + } + + #[test] + fn escape_leaves_the_grid_then_the_zoom_and_blank_toggles() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.grid = true; + show.zoom = Some(Zoom::at(0.2, 0.3)); + assert!(show.back_out()); + assert!(!show.grid); + assert!(show.zoom.is_some(), "closing the grid leaves the zoom"); + assert!(show.back_out()); + assert!(show.zoom.is_none()); + assert!(!show.back_out()); + show.zoom_key(); + assert!(show.zoom_arm); + show.zoom_key(); + assert!(!show.zoom_arm); + toggle_blank(&mut show, Color32::BLACK); + assert_eq!(show.blank_name(), Some("black")); + toggle_blank(&mut show, Color32::WHITE); + assert_eq!(show.blank_name(), Some("white")); + toggle_blank(&mut show, Color32::WHITE); + assert_eq!(show.blank_name(), None); + show.zoom = Some(Zoom::at(0.4, 0.6)); + assert!(show.apply(ShowAction::Slide(1), &doc, 1.0)); + assert!(show.zoom.is_none(), "a new slide is shown whole"); + } + + #[test] + fn presenter_timer_pauses_resets_and_notes_size_clamps() { + let mut t = PresenterTimer::start(10.0); + assert!((t.elapsed(12.5) - 2.5).abs() < 1e-9); + t.toggle(15.0); + assert!(t.paused()); + assert!((t.elapsed(100.0) - 5.0).abs() < 1e-9); + t.reset(100.0); + assert!(t.paused()); + assert_eq!(t.elapsed(200.0), 0.0); + t.toggle(200.0); + assert!(!t.paused()); + assert!((t.elapsed(203.0) - 3.0).abs() < 1e-9); + t.reset(203.0); + assert!(!t.paused()); + assert!((t.elapsed(204.0) - 1.0).abs() < 1e-9); + let bad = PresenterTimer::start(f64::NAN); + assert_eq!(bad.elapsed(f64::INFINITY), 0.0); + assert_eq!(format_elapsed(3661.4), "01:01:01"); + assert_eq!(format_elapsed(-1.0), "00:00:00"); + assert_eq!(format_elapsed(f64::NAN), "00:00:00"); + let clock = wall_clock(); + assert!(clock == "--:--" || (clock.len() == 5 && clock.as_bytes().get(2) == Some(&b':'))); + + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + assert_eq!(show.notes_px, NOTES_DEFAULT); + show.nudge_notes(NOTES_STEP); + assert_eq!(show.notes_px, 22.0); + show.nudge_notes(1000.0); + assert_eq!(show.notes_px, NOTES_MAX); + show.nudge_notes(-1000.0); + assert_eq!(show.notes_px, NOTES_MIN); + show.nudge_notes(f32::NAN); + assert_eq!(show.notes_px, NOTES_MIN); + let v = show.showing_value(1, 16.0 / 9.0, Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), 0.0); + assert_eq!(v["presenterPaused"], json!(false)); + assert_eq!(v["notesPx"], json!(NOTES_MIN)); + } + + #[test] + fn a_typed_slide_number_jumps_and_then_expires() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, false); + show.push_slide_number('1', 10.0); + show.push_slide_number('2', 10.2); + assert_eq!(show.goto, "12"); + assert_eq!(show.take_goto(), Some(11)); + assert!(show.goto.is_empty()); + show.push_slide_number('0', 1.0); + assert_eq!(show.take_goto(), None, "slide 0 is not a slide"); + show.push_slide_number('4', 5.0); + show.expire_goto(7.0); + assert_eq!(show.goto, "4"); + show.expire_goto(8.1); + assert!(show.goto.is_empty()); + show.push_slide_number('9', 0.0); + assert!(show.clear_goto()); + assert!(!show.clear_goto()); + for _ in 0..6 { + show.push_slide_number('9', 1.0); + } + assert_eq!(show.goto.len(), GOTO_DIGITS); + show.push_slide_number('a', 1.0); + assert!(show.goto.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn the_next_preview_fits_its_column_whatever_the_slide_shape() { + let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0)); + let mut s = deckcraft_engine::Session::with_new(); + for (w, h) in [(72, 4032), (4032, 72), (960, 540)] { + s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let r = next_rect(right, &doc); + assert!(right.contains_rect(r), "{w}x{h}: {r:?}"); + let aspect = r.height() / r.width(); + assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}"); + } + // 16:9 still takes the column's full width. + let doc = s.active().unwrap().doc.clone(); + assert_eq!(next_rect(right, &doc).width(), 400.0); + } } diff --git a/crates/ui-egui/src/size_position_tests.rs b/crates/ui-egui/src/size_position_tests.rs new file mode 100644 index 00000000..26baf713 --- /dev/null +++ b/crates/ui-egui/src/size_position_tests.rs @@ -0,0 +1,73 @@ +//! Size and Position dialog: the Shape Format launcher opens it, and OK applies one undo step. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(deckcraft_engine::Session::with_new(), Services::default()), + ); + h.run_steps(2); + h +} + +fn box_of(app: &SlideApp, id: u64) -> (f64, f64, f64, f64, f64) { + let st = app.session.active().unwrap(); + let x = st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap(); + (x.x, x.y, x.w, x.h, x.rot) +} + +#[test] +fn size_button_sets_width_in_one_undo() { + let mut h = harness(); + let id = h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + h.state_mut().ui.tab = "Shape Format".into(); + h.run_steps(2); + h.get_by_label("Size and Position…").click(); + h.run_steps(2); + let dlg = h.state().dialog.as_ref().unwrap(); + assert_eq!(dlg.id, "sizePosition"); + assert_eq!(dlg.fields.get("w").map(String::as_str), Some("2.78")); + assert_eq!(dlg.fields.get("h").map(String::as_str), Some("1.39")); + assert_eq!(dlg.fields.get("lock").map(String::as_str), Some("0")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("w".into(), "4".into()); + h.run_steps(1); + h.get_by_label("OK").click_accesskit(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(box_of(h.state(), id), (40.0, 50.0, 288.0, 100.0, 0.0)); + let undo = &h.state().session.active().unwrap().history.undo; + assert_eq!(undo.last().unwrap().label, "Size and Position…"); + let n = undo.len(); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(box_of(h.state(), id), (40.0, 50.0, 200.0, 100.0, 0.0)); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), n - 1); +} + +#[test] +fn lock_aspect_from_the_dialog_scales_height() { + let mut h = harness(); + let id = h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + h.state_mut().run("shape.sizePosition", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().id, "sizePosition"); + { + let fields = &mut h.state_mut().dialog.as_mut().unwrap().fields; + fields.insert("lock".into(), "1".into()); + fields.insert("w".into(), "4".into()); + } + h.get_by_label("OK").click_accesskit(); + h.run_steps(2); + assert_eq!(box_of(h.state(), id), (40.0, 50.0, 288.0, 144.0, 0.0)); +} diff --git a/crates/ui-egui/src/slide_size_tests.rs b/crates/ui-egui/src/slide_size_tests.rs new file mode 100644 index 00000000..0ad77994 --- /dev/null +++ b/crates/ui-egui/src/slide_size_tests.rs @@ -0,0 +1,58 @@ +//! Slide sizes at the edges of what a loaded file can give (after `deckcraft_format::repair`): +//! the editor, sorter, slide show and presenter view open without panicking. (NaN and infinity +//! are saved as null, which used to make the file fail to open.) +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn open_with_size(w: f64, h: f64) -> egui_kittest::Harness<'static, SlideApp> { + // Written with the size, then opened the way File › Open does (load, then repair). + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).slide_size = deckcraft_model::Size::new(w, h); + let path = std::env::temp_dir().join(format!("deckcraft-size-{}-{w}x{h}.deckcraft", std::process::id())); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ) +} + +#[test] +fn extreme_loaded_slide_sizes_open_in_every_view() { + for (w, h) in [(0.0, 0.0), (-5.0, 540.0), (f64::NAN, 540.0), (f64::INFINITY, 1.0), (1.0, 1.0), (1e9, 1e9), (4000.0, 4000.0)] { + let mut hs = open_with_size(w, h); + hs.run_steps(3); + let size = hs.state().session.active().unwrap().doc.slide_size; + assert!((72.0..=4032.0).contains(&size.width) && (72.0..=4032.0).contains(&size.height), "{w}x{h}: {size:?}"); + for cmd in ["view.sorter", "view.normal"] { + hs.state_mut().run(cmd, json!({})).unwrap(); + hs.run_steps(2); + } + hs.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + hs.state_mut().run("show.start", json!({"from": 0})).unwrap(); + hs.run_steps(3); + assert!(hs.state().show.is_some(), "{w}x{h}"); + } +} + +/// A presentation whose size went bad in memory (not through a file) still lays out: the +/// canvas fit stays finite and positive. +#[test] +fn the_canvas_fit_survives_a_zero_slide_size_in_memory() { + let mut s = Session::with_new(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).slide_size = deckcraft_model::Size::new(0.0, f64::NAN); + let app = SlideApp::new(s, Services::default()); + let (r, scale) = crate::canvas::layout(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0))); + assert!(scale.is_finite() && scale > 0.0, "{scale}"); + assert!(r.min.x.is_finite() && r.max.y.is_finite(), "{r:?}"); +} diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/smartart_tests.rs new file mode 100644 index 00000000..0e668439 --- /dev/null +++ b/crates/ui-egui/src/smartart_tests.rs @@ -0,0 +1,111 @@ +//! The SmartArt Text Pane driven like a person would: type in a line, Enter for a new line, Tab +//! to demote. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn levels(h: &Harness<'_, SlideApp>) -> Vec<(String, u8)> { + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).unwrap(); + d.nodes.into_iter().map(|n| (n.text, n.level)).collect() +} + +fn line<'a>(h: &'a Harness<'_, SlideApp>, v: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_value(v).find(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput).unwrap() +} + +#[test] +fn text_pane_types_adds_and_demotes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "Build"]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("smartart".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_label("Text Pane").is_some()); + + line(&h, "Build").click(); + h.run_steps(2); + h.key_press(egui::Key::End); + line(&h, "Build").type_text(" it"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), (String::new(), 0)]); + + // The new line has the focus: type, then Tab demotes it under "Build it". + h.event(egui::Event::Text("Test".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]); +} + +#[test] +fn home_convert_to_smartart_gallery_converts_the_text() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"text": "Goals\nRevenue\nGrowth"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Convert to SmartArt").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Basic Block List").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let (_, d) = deckcraft_engine::cmd::smartart::selected(st).expect("the text became a SmartArt graphic"); + assert_eq!(d.kind, "blockList"); + assert_eq!(d.nodes.len(), 3); +} + +#[test] +fn layout_gallery_shows_cached_pictures_and_previews_on_hover() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.smartArt", &json!({"kind": "process", "items": ["Plan", "Build", "Ship"]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "SmartArt Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Layouts").click(); + h.run_steps(3); + let first = h.state().textures.smartart_cached(); + assert!(first >= deckcraft_engine::cmd::smartart::LAYOUTS.len().min(8), "gallery pictures were drawn: {first}"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").hover(); + h.run_steps(3); + if let Ok(path) = std::env::var("SMARTART_GALLERY_PNG") { + h.render().expect("render").save(path).unwrap(); + } + let hovered = h.state().textures.smartart_cached(); + assert!(hovered > first, "hovering drew a preview of the graphic's own outline"); + h.run_steps(3); + assert_eq!(h.state().textures.smartart_cached(), hovered, "pictures are cached, not redrawn every frame"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Vertical Box List").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(deckcraft_engine::cmd::smartart::selected(st).unwrap().1.kind, "verticalBoxList"); +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..4c97605c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -1,86 +1,332 @@ //! Slide Sorter, Notes Page and the Outline pane. -use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use std::collections::HashSet; + +use deckcraft_model::{Presentation, SlideId}; +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; use serde_json::json; use crate::SlideApp; +use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +/// Sorter-only state. Nothing here is part of the document. +#[derive(Debug, Default)] +pub struct State { + /// Sections folded shut, keyed by (document uid, that section's first slide) so moving a + /// section does not fold a different one. An empty section has nothing to hide. + pub collapsed: HashSet<(u64, u32)>, + /// Inline rename: (document uid, section index, text typed so far). + pub renaming: Option<(u64, usize, String)>, + rename_started: bool, + /// Whole-section drag: (section index, insertion slot among the bars). + pub drag: Option<(usize, usize)>, +} + +/// Where a dragged section would land. `centers` are the header centre-y values, top to bottom; +/// `slot` is the gap (0 = before the first bar, `len` = after the last). +fn drop_slot(centers: &[f32], y: f32) -> usize { + centers.iter().position(|cy| y < *cy).unwrap_or(centers.len()) +} + +/// `section.move`'s `to` for a drag from `from` released at `slot`. `None` when the section +/// would stay where it is (the gap before it, or the gap just after it). +fn move_dest(from: usize, slot: usize) -> Option { + if slot == from || slot == from + 1 { None } else { Some(if slot > from { slot - 1 } else { slot }) } +} + +/// One section's slides, in the section's order, then any slide that belongs to none. +fn groups(doc: &Presentation) -> Vec<(Option, Vec)> { + if doc.sections.is_empty() { + return vec![(None, (0..doc.slides.len()).collect())]; + } + let mut seen = vec![false; doc.slides.len()]; + let mut out = Vec::with_capacity(doc.sections.len() + 1); + for (si, sec) in doc.sections.iter().enumerate() { + let mut slides = Vec::with_capacity(sec.slides.len()); + for id in &sec.slides { + if let Some(i) = doc.slide_index(*id) + && let Some(flag) = seen.get_mut(i) + && !*flag + { + *flag = true; + slides.push(i); + } + } + out.push((Some(si), slides)); + } + let rest: Vec = seen.iter().enumerate().filter(|(_, on)| !**on).map(|(i, _)| i).collect(); + if !rest.is_empty() { + out.push((None, rest)); + } + out +} + +struct Header<'a> { + uid: u64, + index: usize, + name: &'a str, + count: usize, + first: Option, +} + +fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover()); + let resp = ui.interact(r, ui.id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, h.name)); + let open = h.first.is_none_or(|id| !app.sorter.collapsed.contains(&(h.uid, id))); + let dragging = app.sorter.drag.is_some_and(|(from, _)| from == h.index); + let bg = if dragging { + t.selected + } else if resp.hovered() { + t.hover + } else { + t.card + }; + let painter = ui.painter_at(r); + painter.rect_filled(r, CornerRadius::same(4), bg); + let chev = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(24.0, 24.0)); + let cresp = ui.interact(chev, ui.id().with(("sorter-fold", h.uid, h.index)), Sense::click()); + let fold = format!("{} {}", if open { "Collapse" } else { "Expand" }, h.name); + cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &fold)); + icons::paint(&painter, chev.shrink(4.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, h.first.is_none()); + let renaming = app.sorter.renaming.clone().filter(|(uid, index, _)| *uid == h.uid && *index == h.index).map(|(_, _, text)| text); + let name_rect = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 4.0), pos2(r.max.x - 56.0, r.max.y - 4.0)); + if let Some(mut buf) = renaming { + let edit_id = ui.id().with(("sorter-rename", h.uid, h.index)); + let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::bold(13.0)).margin(vec2(4.0, 2.0))); + let already = app.sorter.rename_started; + if !already { + out.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), edit_id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), edit_id); + } + app.sorter.rename_started = true; + } + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + // The frame that asks for focus has not had it yet; committing then would cancel the rename. + if already && (out.lost_focus() || enter || esc) { + app.sorter.renaming = None; + app.sorter.rename_started = false; + let trimmed = buf.trim(); + if !esc && !trimmed.is_empty() && trimmed != h.name { + let _ = app.run("section.rename", json!({"index": h.index, "name": trimmed})); + } + } else if let Some((_, _, text)) = app.sorter.renaming.as_mut() { + *text = buf; + } + } else { + painter.text(pos2(name_rect.min.x, r.center().y), Align2::LEFT_CENTER, h.name, theme::bold(13.0), t.text); + } + painter.text(pos2(r.max.x - 16.0, r.center().y), Align2::RIGHT_CENTER, format!("({})", h.count), theme::font(12.0), t.text_dim); + if cresp.clicked() + && let Some(id) = h.first + { + let key = (h.uid, id); + if open { + app.sorter.collapsed.insert(key); + } else { + app.sorter.collapsed.remove(&key); + } + } else if resp.double_clicked() && app.sorter.renaming.is_none() { + app.sorter.renaming = Some((h.uid, h.index, h.name.to_string())); + app.sorter.rename_started = false; + } + let on_chevron = resp.interact_pointer_pos().is_some_and(|p| chev.contains(p)); + if resp.drag_started() && !on_chevron && app.sorter.renaming.is_none() { + app.sorter.drag = Some((h.index, h.index)); + app.thumb_drag = None; + } + let index = h.index; + let name = h.name.to_string(); + resp.context_menu(|ui| { + if ui.button("Rename Section…").clicked() { + let mut d = crate::dialogs::Dialog::new("renameSection"); + d.params = json!({"index": index, "name": name}); + app.dialog = Some(d); + ui.close(); + } + if ui.button("Add Section").clicked() { + if let Some(at) = app.session.active().map(|st| st.selection.slide) { + let _ = app.run("section.add", json!({"at": at})); + } + ui.close(); + } + if ui.button("Remove Section").clicked() { + let _ = app.run("section.remove", json!({"index": index})); + ui.close(); + } + if ui.button("Remove Section & Slides").clicked() { + let _ = app.run("section.remove", json!({"index": index, "slides": true})); + ui.close(); + } + if ui.button("Move Section Up").clicked() { + let _ = app.run("section.move", json!({"index": index, "to": index.saturating_sub(1)})); + ui.close(); + } + if ui.button("Move Section Down").clicked() { + let _ = app.run("section.move", json!({"index": index, "to": index + 1})); + ui.close(); + } + }); + r +} + +struct Grid<'a> { + doc: &'a Presentation, + indices: &'a [usize], + cur: usize, + multi: &'a [SlideId], + tw: f32, + th: f32, +} + +/// One section's thumbnails. Returns whether a thumbnail is still being drawn. +fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, rects: &mut Vec<(usize, Rect)>) -> bool { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + let cols = ((ui.available_width() - 32.0) / (g.tw + 36.0)).floor().max(1.0) as usize; + let mut need_more = false; + let rows = g.indices.len().div_ceil(cols); + for row in 0..rows { + ui.horizontal(|ui| { + ui.add_space(24.0); + for c in 0..cols { + let Some(&i) = g.indices.get(row * cols + c) else { break }; + let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag()); + let label = format!("Slide {}", i + 1); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp); + rects.push((i, cell)); + let p = ui.painter(); + match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) { + Some(tex) => { + p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); + need_more = true; + } + } + let id = g.doc.slides.get(i).map(|s| s.id); + let selected = i == g.cur || id.is_some_and(|id| g.multi.contains(&id)); + p.rect_stroke( + img.expand(if selected { 2.0 } else { 0.0 }), + CornerRadius::same(3), + Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim); + if let Some(tr) = g.doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|tr| tr.kind != "none") { + p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint); + } + if resp.clicked() { + let add = ui.input(|inp| inp.modifiers.command); + if add { + if let Some(id) = id { + let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); + } + } else { + let _ = app.run("slide.go", json!({"index": i})); + } + } + if resp.double_clicked() { + let _ = app.run("slide.go", json!({"index": i})); + app.ui.view = crate::ViewMode::Normal; + } + if resp.drag_started() && app.sorter.drag.is_none() { + app.thumb_drag = Some((i, i)); + } + resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i)); + } + }); + } + need_more +} + /// Grid of slides; click selects, double-click opens in Normal view, drag reorders. +/// With sections, each one is a bar: add, rename, collapse, and drag the whole section. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; + let Some(st) = app.session.active() else { + app.sorter.drag = None; + app.sorter.renaming = None; + app.sorter.rename_started = false; + return; + }; + let uid = st.uid; + if app.sorter.renaming.as_ref().is_some_and(|(u, i, _)| *u != uid || *i >= st.doc.sections.len()) { + app.sorter.renaming = None; + app.sorter.rename_started = false; + } let doc = st.doc.clone(); let cur = st.selection.slide; let multi = st.selection.slides.clone(); let zoom = app.ui.sorter_zoom.clamp(0.5, 3.0); + let grouped = groups(&doc); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - ui.add_space(16.0); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.add_space(24.0); + if ui.add(egui::Button::new("Add Section").stroke(Stroke::new(1.0, t.border))).clicked() { + let _ = app.run("section.add", json!({"at": cur})); + } + }); + ui.add_space(8.0); let tw = 200.0 * zoom; let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let ppp = ui.ctx().pixels_per_point(); - let cols = ((ui.available_width() - 32.0) / (tw + 36.0)).floor().max(1.0) as usize; let mut budget = 4u32; let mut rects = vec![]; + let mut headers: Vec = Vec::new(); let mut need_more = false; - for row in 0..doc.slides.len().div_ceil(cols) { - ui.horizontal(|ui| { - ui.add_space(24.0); - for c in 0..cols { - let i = row * cols + c; - if i >= doc.slides.len() { - break; - } - let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag()); - let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)); - rects.push((i, cell)); - let p = ui.painter(); - match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) { - Some(tex) => { - p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - None => { - p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); - need_more = true; - } - } - let id = doc.slides.get(i).map(|s| s.id); - let selected = i == cur || id.is_some_and(|id| multi.contains(&id)); - p.rect_stroke( - img.expand(if selected { 2.0 } else { 0.0 }), - CornerRadius::same(3), - Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }), - egui::StrokeKind::Outside, - ); - p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim); - if let Some(tr) = doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|t| t.kind != "none") { - p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint); - } - if resp.clicked() { - let add = ui.input(|i| i.modifiers.command); - if add { - if let Some(id) = id { - let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); - } - } else { - let _ = app.run("slide.go", json!({"index": i})); - } - } - if resp.double_clicked() { - let _ = app.run("slide.go", json!({"index": i})); - app.ui.view = crate::ViewMode::Normal; - } - if resp.drag_started() { - app.thumb_drag = Some((i, i)); - } - resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i)); + for (si, slides) in &grouped { + if let Some(si) = *si + && let Some(sec) = doc.sections.get(si) + { + let first = sec.slides.first().map(|id| id.0); + let rect = section_header(app, ui, &Header { uid, index: si, name: &sec.name, count: sec.slides.len(), first }); + headers.push(rect); + let folded = first.is_some_and(|id| app.sorter.collapsed.contains(&(uid, id))); + if folded { + ui.add_space(8.0); + continue; } - }); + } + let grid = Grid { doc: &doc, indices: slides, cur, multi: &multi, tw, th }; + if place_slides(app, ui, &grid, &mut budget, &mut rects) { + need_more = true; + } + ui.add_space(8.0); } if need_more { ui.ctx().request_repaint(); } - if let Some((from, _)) = app.thumb_drag { + if let Some((from, _)) = app.sorter.drag { + if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { + let centers: Vec = headers.iter().map(|r| r.center().y).collect(); + let slot = drop_slot(¢ers, pp.y); + app.sorter.drag = Some((from, slot)); + let y = headers.get(slot).map(|r| r.min.y).or_else(|| headers.last().map(|r| r.max.y + 2.0)); + if let Some(y) = y { + ui.painter().line_segment([pos2(16.0, y), pos2(ui.available_width(), y)], Stroke::new(2.0, t.accent)); + } + } + if ui.input(|i| i.pointer.any_released()) { + if let Some((from, slot)) = app.sorter.drag.take() + && let Some(to) = move_dest(from, slot) + { + let _ = app.run("section.move", json!({"index": from, "to": to})); + } + app.sorter.drag = None; + } + } + if app.sorter.drag.is_none() + && let Some((from, _)) = app.thumb_drag + { if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { let to = rects.iter().find(|(_, r)| r.contains(pp)).map(|(i, r)| if pp.x > r.center().x { i + 1 } else { *i }).unwrap_or(doc.slides.len()); @@ -121,33 +367,62 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let page = Rect::from_center_size(avail.center(), vec2(page_w, page_h)); ui.painter().rect_filled(page.translate(vec2(2.0, 2.0)), CornerRadius::ZERO, t.shadow); ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); - let sw = page_w * 0.78; - let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)); + // The notes master places the slide, the notes and the header and footer text (in Letter + // points, as the PDF notes pages do). + let paper = (612.0, 792.0); + let k = page_w / paper.0 as f32; + let to_screen = |(x0, y0, x1, y1): (f64, f64, f64, f64)| { + Rect::from_min_max(page.min + vec2(x0 as f32 * k, y0 as f32 * k), page.min + vec2(x1 as f32 * k, y1 as f32 * k)) + }; + let (slide_box, notes_box) = deckcraft_engine::pdf::notes_frames(&doc, paper); + // On the physical pixel grid, so the thumbnail is one texel per device pixel (#184). + let srect = crate::textures::device_pixel_rect(to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1)), ui.ctx().pixels_per_point()); + if let Some(m) = doc.notes_master.as_deref() { + // Laid out by the text engine as on the PDF page: wrapped, each run in its size and colour. + for piece in deckcraft_engine::pdf::master_text_pieces(&doc, m, paper, idx + 1) { + let size = (piece.size as f32 * k).max(4.0); + let at = page.min + vec2(piece.at.0 as f32 * k, piece.at.1 as f32 * k - size * 0.8); + let c = Color32::from_rgba_unmultiplied(piece.color.r, piece.color.g, piece.color.b, piece.color.a); + ui.painter().text(at, egui::Align2::LEFT_TOP, &piece.text, theme::font(size), c); + } + } let ppp = ui.ctx().pixels_per_point(); let mut budget = 2; - if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) { + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) { ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); - let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); + let notes_rect = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); - let id = egui::Id::new(("notespage", idx)); - let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); - let r = child.add( - egui::TextEdit::multiline(&mut buf) - .hint_text("Click to add text") - .desired_width(notes_rect.width()) - .desired_rows(12) - .frame(egui::Frame::NONE) - .text_color(Color32::from_gray(30)) - .font(theme::font(14.0)), - ); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + // A draft belongs to this document and to the notes it started from (#413): another document, + // a reorder or an undo that changes them drops it. + let id = egui::Id::new(("notespage", st.uid, idx)); + let edit_id = egui::Id::new(("notespage-edit", idx)); + let focused = child.ctx().memory(|m| m.has_focus(edit_id)); + let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default(); + let notes = notes_of(app); + let stored: Option<(deckcraft_model::text::TextBody, crate::notes::Draft)> = child.data_mut(|d| d.get_temp(id)); + let mut draft = stored.filter(|(base, _)| *base == notes).map(|(_, d)| d); + // Save an edited draft once the field has lost focus, before anything reloads it. + if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) { + d.edited = false; + let _ = app.run("slide.notes", json!({"body": d.body, "index": idx})); } - child.data_mut(|d| d.insert_temp(id, buf)); + let notes = notes_of(app); + let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es)); + let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; + let width = notes_rect.width(); + let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { + e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + }) + .response + .response; + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); + } + let base = notes_of(app); + child.data_mut(|d| d.insert_temp(id, (base, draft))); let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp))); if nav.0 { let _ = app.run("slide.next", json!({})); @@ -166,67 +441,96 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let cur = st.selection.slide; - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + // Only the slides in view (and the current one, whose editor may have the focus) are + // laid out; the rest take their last measured height (estimated until first shown). + // Laying out every slide's editors each frame made a 2000-slide outline crawl. + egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let origin = ui.cursor().top(); + let margin = 300.0; for (i, s) in doc.slides.iter().enumerate() { - let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim)); - let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover()); - ui.painter().rect_stroke( - r, - CornerRadius::same(1), - Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), - egui::StrokeKind::Inside, - ); - let id = egui::Id::new(("ol-title", s.id.0)); - let cur_title = s.title(); - let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone())); - let resp = - ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY)); - if resp.gained_focus() { - let _ = app.run("slide.go", json!({"index": i})); - } - if resp.lost_focus() - && v != cur_title - && let Some(tid) = title_shape - { - let _ = app.run("text.set", json!({"id": tid, "text": v})); - } - if !resp.has_focus() { - ui.data_mut(|d| d.remove::(id)); - } else { - ui.data_mut(|d| d.insert_temp(id, v)); - } - }); - for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) { - let Some(tb) = &sh.text else { continue }; - if tb.is_empty() { - continue; - } - let id = egui::Id::new(("ol-body", s.id.0, sh.id.0)); - let cur_text: String = - tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::>().join("\n"); - let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone())); - ui.horizontal(|ui| { - ui.add_space(36.0); - let resp = - ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); - if resp.gained_focus() { - let _ = app.run("slide.go", json!({"index": i})); - } - if resp.lost_focus() && v != cur_text { - let _ = app.run("text.set", json!({"id": sh.id.0, "text": v})); - } - if !resp.has_focus() { - ui.data_mut(|d| d.remove::(id)); - } else { - ui.data_mut(|d| d.insert_temp(id, v.clone())); - } - }); + let hid = egui::Id::new(("ol-height", s.id.0)); + let known: Option = ui.data(|d| d.get_temp(hid)); + let h = known.unwrap_or_else(|| outline_estimate(s)); + let y = ui.cursor().top() - origin; + if i == cur || (y + h >= viewport.min.y - margin && y <= viewport.max.y + margin) { + outline_slide(app, ui, &t, i, s, cur); + let real = ui.cursor().top() - origin - y; + ui.data_mut(|d| d.insert_temp(hid, real)); + } else { + ui.allocate_exact_size(vec2(ui.available_width(), (h - ui.spacing().item_spacing.y).max(0.0)), Sense::hover()); } - ui.add_space(6.0); } }); }, ); } + +#[cfg(test)] +#[path = "sorter_tests.rs"] +mod ui_tests; + +/// A slide's outline block height before it has been laid out: title row plus its text lines. +fn outline_estimate(s: &deckcraft_model::Slide) -> f32 { + let lines: usize = s + .shapes + .iter() + .filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) + .filter_map(|x| x.text.as_ref()) + .filter(|tb| !tb.is_empty()) + .map(|tb| tb.paragraphs.len().max(1)) + .sum(); + 30.0 + lines as f32 * 19.0 +} + +/// One slide in the outline: number, title editor, then its other text. +fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deckcraft_model::Slide, cur: usize) { + let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim)); + let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover()); + ui.painter().rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), egui::StrokeKind::Inside); + let id = egui::Id::new(("ol-title", s.id.0)); + let cur_title = s.title(); + let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone())); + let resp = ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY)); + if resp.gained_focus() { + let _ = app.run("slide.go", json!({"index": i})); + } + if resp.lost_focus() + && v != cur_title + && let Some(tid) = title_shape + { + let _ = app.run("text.set", json!({"id": tid, "text": v})); + } + if !resp.has_focus() { + ui.data_mut(|d| d.remove::(id)); + } else { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); + for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) { + let Some(tb) = &sh.text else { continue }; + if tb.is_empty() { + continue; + } + let id = egui::Id::new(("ol-body", s.id.0, sh.id.0)); + let cur_text: String = tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::>().join("\n"); + let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone())); + ui.horizontal(|ui| { + ui.add_space(36.0); + let resp = ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); + if resp.gained_focus() { + let _ = app.run("slide.go", json!({"index": i})); + } + if resp.lost_focus() && v != cur_text { + let _ = app.run("text.set", json!({"id": sh.id.0, "text": v})); + } + if !resp.has_focus() { + ui.data_mut(|d| d.remove::(id)); + } else { + ui.data_mut(|d| d.insert_temp(id, v.clone())); + } + }); + } + ui.add_space(6.0); +} diff --git a/crates/ui-egui/src/sorter_tests.rs b/crates/ui-egui/src/sorter_tests.rs new file mode 100644 index 00000000..3ab3d215 --- /dev/null +++ b/crates/ui-egui/src/sorter_tests.rs @@ -0,0 +1,171 @@ +//! Slide Sorter sections: add, rename, collapse, and dragging a whole section. + +use deckcraft_engine::Session; +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness(with_sections: bool) -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + for _ in 0..3 { + s.execute("slide.new", &json!({})).unwrap(); + } + if with_sections { + s.execute("section.add", &json!({"name": "Alpha", "at": 0})).unwrap(); + s.execute("section.add", &json!({"name": "Beta", "at": 2})).unwrap(); + } + let mut app = SlideApp::new(s, Services::default()); + app.ui.view = crate::ViewMode::Sorter; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn section_names(h: &Harness<'_, SlideApp>) -> Vec { + h.state().session.active().unwrap().doc.sections.iter().map(|s| s.name.clone()).collect() +} + +fn section_slides(h: &Harness<'_, SlideApp>) -> Vec> { + h.state().session.active().unwrap().doc.sections.iter().map(|s| s.slides.iter().map(|id| id.0).collect()).collect() +} + +fn double_click(h: &mut Harness<'_, SlideApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.hover_at(from + (to - from) * t); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +#[test] +fn drop_slot_and_move_dest() { + assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 0.0), 0); + assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 20.0), 1); + assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 100.0), 3); + assert_eq!(super::move_dest(1, 1), None); + assert_eq!(super::move_dest(1, 2), None); + assert_eq!(super::move_dest(1, 0), Some(0)); + assert_eq!(super::move_dest(0, 2), Some(1)); +} + +#[test] +fn add_section_splits_at_the_current_slide() { + let mut h = harness(false); + assert!(h.query_by_label("Slide 4").is_some()); + assert!(section_names(&h).is_empty()); + h.get_by_label("Add Section").click(); + h.run_steps(3); + assert_eq!(section_names(&h), ["Default Section", "Untitled Section"]); + let slides = section_slides(&h); + assert_eq!(slides[0].len(), 3); + assert_eq!(slides[1].len(), 1); + assert!(h.query_by_label("Default Section").is_some()); + assert!(h.query_by_label("Untitled Section").is_some()); + assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 4").is_some()); +} + +#[test] +fn collapse_hides_that_sections_slides_and_expand_brings_them_back() { + let mut h = harness(true); + assert_eq!(section_names(&h), ["Alpha", "Beta"]); + assert_eq!(section_slides(&h).iter().map(|s| s.len()).collect::>(), [2, 2]); + assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 4").is_some()); + h.get_by_label("Collapse Beta").click(); + h.run_steps(2); + assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 2").is_some()); + assert!(h.query_by_label("Slide 3").is_none() && h.query_by_label("Slide 4").is_none()); + assert!(h.query_by_label("Expand Beta").is_some()); + h.get_by_label("Expand Beta").click(); + h.run_steps(2); + assert!(h.query_by_label("Slide 3").is_some() && h.query_by_label("Slide 4").is_some()); +} + +#[test] +fn double_click_renames_and_switching_documents_drops_the_draft() { + let mut h = harness(true); + let beta = h.get_by_label("Beta").rect().center(); + double_click(&mut h, beta); + assert!(h.state().sorter.renaming.is_some(), "double-click starts renaming"); + h.event(egui::Event::Text("Gamma".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(section_names(&h), ["Alpha", "Gamma"]); + assert!(h.state().sorter.renaming.is_none()); + + // Past the double-click window, so the next two clicks count as a new double-click. + h.run_steps(25); + let gamma = h.get_by_label("Gamma").rect().center(); + double_click(&mut h, gamma); + assert!(h.state().sorter.renaming.is_some()); + h.event(egui::Event::Text("Nope".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(section_names(&h), ["Alpha", "Gamma"]); + assert!(h.query_by_label("Nope").is_none()); + + h.run_steps(25); + let gamma = h.get_by_label("Gamma").rect().center(); + double_click(&mut h, gamma); + assert!(h.state().sorter.renaming.is_some()); + h.event(egui::Event::Text("Draft".into())); + h.run_steps(1); + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().sorter.renaming.is_none(), "a different presentation drops the draft"); + h.state_mut().session.set_active(0); + h.run_steps(2); + assert_eq!(section_names(&h), ["Alpha", "Gamma"]); +} + +#[test] +fn dragging_a_section_moves_it_and_keeps_it_collapsed() { + let mut h = harness(true); + let before = section_slides(&h); + h.get_by_label("Collapse Beta").click(); + h.run_steps(2); + let (alpha, beta) = (h.get_by_label("Alpha").rect(), h.get_by_label("Beta").rect()); + assert!(beta.top() > alpha.bottom()); + drag(&mut h, beta.center(), Pos2::new(alpha.center().x, alpha.top() - 4.0)); + assert_eq!(section_names(&h), ["Beta", "Alpha"]); + let after = section_slides(&h); + assert_eq!(after[0], before[1]); + assert_eq!(after[1], before[0]); + let doc = &h.state().session.active().unwrap().doc; + let order: Vec = doc.slides.iter().map(|s| s.id.0).collect(); + let expect: Vec = after.iter().flatten().copied().collect(); + assert_eq!(order, expect, "slides follow the section order"); + assert!(h.query_by_label("Expand Beta").is_some(), "Beta stays collapsed after the move"); + assert!(h.query_by_label("Slide 1").is_none() && h.query_by_label("Slide 2").is_none()); + assert!(h.query_by_label("Slide 3").is_some() && h.query_by_label("Slide 4").is_some()); + let (beta, alpha) = (h.get_by_label("Beta").rect(), h.get_by_label("Alpha").rect()); + assert!(beta.top() < alpha.top()); +} diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs new file mode 100644 index 00000000..e88ea23f --- /dev/null +++ b/crates/ui-egui/src/spelling_tests.rs @@ -0,0 +1,279 @@ +//! The Spelling dialog with a dictionary: suggestions change the word, Ignore All hides it. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn spelling_dialog_changes_and_ignores_words() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "6\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\n"; + deckcraft_engine::spell::install("en-US", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 500, 120], "text": "Our plna for teh quarter"})).unwrap(); + app.dialog = Some(crate::dialogs::Dialog::new("spelling")); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT"), h.render()) { + let _ = img.save(path); + } + h.get_by_label("plan").click(); + h.run_steps(3); + let text = |h: &Harness<'_, SlideApp>| { + let st = h.state().session.active().unwrap(); + let mut out = String::new(); + deckcraft_model::walk(&st.doc.slides[0].shapes, &mut |s, _| { + if let Some(t) = &s.text { + out += &t.text(); + } + }); + out + }; + assert!(text(&h).contains("Our plan for teh"), "{}", text(&h)); + h.get_by_label("Ignore All").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("No issues found").is_some()); +} + +/// Misspelt words on the slide get squiggles from the background check; not the word being +/// typed; the context menu's correction and Add to Dictionary clear them. +#[test] +fn squiggles_mark_misspelt_words_in_the_background() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "7\nthe/S\nslide/S\nquarter/S\nplan/S\nour\nfor\nreview\n"; + deckcraft_engine::spell::install("xx-SQ", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let dir = std::env::temp_dir().join(format!("deckcraft-ui-userdic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + deckcraft_engine::spell::set_user_dictionary_path(dir.join("user.dic")); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app + .session + .execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 700, 160], "text": "Our plna for teh quarter reveiw"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("format.size", &json!({"size": 40})).unwrap(); + // Mark the runs' language directly (what a PPTX run's `lang` gives). + let sid = deckcraft_model::ShapeId(id as u32); + app.session + .edit(|doc, _| { + let sl = std::sync::Arc::make_mut(&mut doc.slides[0]); + let sh = deckcraft_model::find_shape_mut(&mut sl.shapes, sid).unwrap(); + for r in sh.text.as_mut().unwrap().paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) { + r.props.lang = Some("xx-SQ".into()); + } + Ok(()) + }) + .unwrap(); + app.session.execute("edit.select", &json!({"ids": []})).unwrap(); + let st = app.session.active().unwrap().clone(); + let lines = app.squiggles.settle(&st); + assert_eq!(lines.len(), 3, "{lines:?}"); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SQUIGGLES"), h.render()) { + let _ = img.save(path); + } + // The context menu's word under the first squiggle, and its correction. + let st = h.state().session.active().unwrap().clone(); + let (a, _) = lines[0]; + let w = h.state().squiggles.word_at(&st, deckcraft_geom::Point::new(a.x + 5.0, a.y - 5.0), 2.0).unwrap(); + assert_eq!(w.word, "plna"); + assert_eq!(w.suggestions.first().map(String::as_str), Some("plan")); + h.state_mut().session.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": w.start, "word": w.word, "to": "plan"})).unwrap(); + h.state_mut().session.execute("review.addToDictionary", &json!({"word": "reveiw"})).unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1); + // Typing at the end of a misspelt word: no squiggle under it until the caret leaves. + h.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + h.state_mut().session.execute("text.edit", &json!({})).unwrap(); + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 16], "caret": [0, 16]})).unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(st.selection.text.as_ref().map(|t| t.caret), Some((0, 16))); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 0, "the word being typed isn't marked"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Table cells and speaker notes are squiggled too, and the UI-thread checker (the web build's) +/// gets there a few milliseconds per frame. +#[test] +fn squiggles_in_table_cells_and_notes_and_on_the_ui_thread() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "10\nthe/S\nplan/S\nsales\nregion/S\ntotal/S\nnorth\nsouth\nremember\nto\nshow\n"; + deckcraft_engine::spell::install("xx-TB", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.squiggles.force_inline = true; + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app.session.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [80, 80, 600, 160]})).unwrap()["id"].as_u64().unwrap(); + let sid = deckcraft_model::ShapeId(id as u32); + let lang = deckcraft_model::RunProps { lang: Some("xx-TB".into()), size: Some(28.0), ..Default::default() }; + app.session + .edit(|doc, _| { + let sl = std::sync::Arc::make_mut(&mut doc.slides[0]); + sl.notes = deckcraft_model::TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![deckcraft_model::Run::with("Remmeber to show the totl", lang.clone())], + ..Default::default() + }], + ..Default::default() + }; + let sh = deckcraft_model::find_shape_mut(&mut sl.shapes, sid).unwrap(); + let deckcraft_model::ShapeKind::Table(tb) = &mut sh.kind else { panic!("not a table") }; + for (r, c, t) in [(0, 0, "Regoin"), (0, 1, "Sales"), (1, 0, "North"), (1, 1, "Soth totals")] { + tb.cell_mut(r, c).unwrap().text.paragraphs = + vec![deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run::with(t, lang.clone())], ..Default::default() }]; + } + Ok(()) + }) + .unwrap(); + app.session.execute("edit.select", &json!({"ids": []})).unwrap(); + let st = app.session.active().unwrap().clone(); + let lines = app.squiggles.settle(&st); + assert_eq!(lines.len(), 2, "Regoin and Soth: {lines:?}"); + // Each under its own cell: the first in the top-left one, the second in the bottom-right one. + assert!(lines[0].0.x < 380.0 && lines[0].0.y < 160.0 && lines[1].0.x > 380.0 && lines[1].0.y > 160.0, "{lines:?}"); + assert_eq!(app.squiggles.settle_notes("Remmeber to show the totl", "xx-TB"), vec![(0, 8), (21, 25)]); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_TABLE"), h.render()) { + let _ = img.save(path); + } + // Correcting a cell's word through the context menu's command (settled again: frames may + // have moved the document on). + let st = h.state().session.active().unwrap().clone(); + let lines = h.state_mut().squiggles.settle(&st); + let (a, _) = lines[0]; + let w = h.state().squiggles.word_at(&st, deckcraft_geom::Point::new(a.x + 3.0, a.y - 5.0), 2.0).unwrap(); + assert_eq!((w.word.as_str(), w.suggestions.first().map(String::as_str)), ("Regoin", Some("Region"))); + h.state_mut() + .session + .execute("review.spellingChange", &json!({"shape": id, "row": 0, "col": 0, "para": 0, "start": w.start, "word": w.word, "to": "Region"})) + .unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1); + // The Spelling dialog's list has the cell and the notes too. + let v = h.state_mut().session.execute("review.spelling", &json!({})).unwrap(); + let words: Vec<&str> = v.as_array().unwrap().iter().filter_map(|w| w["word"].as_str()).collect(); + assert_eq!(words, vec!["Soth", "Remmeber", "totl"]); +} + +fn right_click(h: &mut Harness<'_, SlideApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::default() }); + } + h.run_steps(3); +} + +/// The notes pane's right-click menu corrects a word and deletes a repeated one; the Language +/// dialog marks text and Do Not Check Spelling clears its squiggles. +#[test] +fn notes_menu_language_dialog_and_repeated_words() { + use egui::accesskit::Role; + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\n"; + let dic = "6\nthe\nplan\nremember\nis\ngood\nwork\n"; + deckcraft_engine::spell::install("xx-NM", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app + .session + .execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 700, 160], "text": "the plan is is good wrok"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("format.size", &json!({"size": 40})).unwrap(); + app.session.execute("format.language", &json!({"lang": "xx-NM"})).unwrap(); + let lang = deckcraft_model::RunProps { lang: Some("xx-NM".into()), ..Default::default() }; + app.session + .edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![deckcraft_model::Run::with("Remmeber the the plan", lang.clone())], + ..Default::default() + }], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap().clone(); + // "is is" is flagged as repeated, "wrok" as misspelt. + assert_eq!(app.squiggles.settle(&st).len(), 2); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(8); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) { + let _ = img.save(path); + } + // Right-click the notes' first word. + let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); + right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0)); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) { + let _ = img.save(path); + } + assert_eq!(h.state().squiggles.notes_menu.as_ref().map(|w| w.word.as_str()), Some("Remmeber")); + h.get_by_label("Remember").click(); + h.run_steps(3); + let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text(); + assert_eq!(notes_text(&h), "Remember the the plan"); + // The repeated "the": its squiggle starts after "Remember the ". + h.run_steps(4); + let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); + let mut found = false; + for dx in (60..160).step_by(6) { + right_click(&mut h, egui::pos2(x.left() + dx as f32, x.top() + 8.0)); + if h.state().squiggles.notes_menu.as_ref().is_some_and(|w| w.delete_from.is_some()) { + found = true; + break; + } + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + } + assert!(found, "no repeated word menu in the notes"); + h.get_by_label("Delete Repeated Word").click(); + h.run_steps(3); + assert_eq!(notes_text(&h), "Remember the plan"); + // Language dialog: Do Not Check Spelling on the shape clears its squiggles. + h.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("language")); + h.run_steps(4); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_LANG"), h.render()) { + let _ = img.save(path); + } + assert_eq!(crate::dialogs::selected_language(h.state()).0, "xx-NM"); + h.get_by_label("Do not check spelling or grammar").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(crate::dialogs::selected_language(h.state()), ("xx-NM".to_string(), true)); + let st = h.state().session.active().unwrap().clone(); + assert!(h.state_mut().squiggles.settle(&st).is_empty(), "nothing in text marked Do Not Check is flagged, repeats included"); +} diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs new file mode 100644 index 00000000..8cd42665 --- /dev/null +++ b/crates/ui-egui/src/squiggles.rs @@ -0,0 +1,505 @@ +//! Spelling squiggles: misspelt words on the current slide (shapes and table cells) and in the +//! speaker notes get a red wavy underline. Checks run on a worker thread so typing never waits for +//! a dictionary; the web build has no threads, so there the same checker runs on the UI thread a +//! few milliseconds per frame until it is done. Results are for one version of the text; while a +//! newer check runs nothing stale is drawn. The word the caret is in isn't marked, as in +//! PowerPoint. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use deckcraft_engine::DocState; +use deckcraft_geom::Point; +use deckcraft_model::{Paragraph, ShapeId}; + +/// Milliseconds of checking per frame on the UI thread (the web build). +pub const FRAME_BUDGET_MS: f64 = 4.0; + +/// Where a checked paragraph is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Tag { + Shape { + id: ShapeId, + para: usize, + }, + Cell { + id: ShapeId, + row: usize, + col: usize, + para: usize, + }, + /// A line of the notes, starting at character `start` of the notes text. + Notes { + start: usize, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Slide, + Notes, +} + +/// What a check is for: a hash of the text's identity, and the user dictionary version. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Key { + of: u64, + user: u64, +} + +struct Job { + kind: Kind, + key: Key, + default_lang: String, + paras: Vec<(Tag, Paragraph)>, + repaint: Option, +} + +/// Misspelt character ranges, per paragraph. +struct Done { + kind: Kind, + key: Key, + ranges: Vec<(Tag, usize, usize)>, +} + +fn now_ms() -> f64 { + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() + } + #[cfg(not(target_arch = "wasm32"))] + { + static T0: std::sync::OnceLock = std::sync::OnceLock::new(); + T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 + } +} + +/// The checking itself, step by step: the newest job of each kind, a paragraph at a time, with +/// results cached by paragraph content (typing re-checks only the paragraph typed in). +#[derive(Default)] +struct Checker { + cache: HashMap>, + user: Option, + /// Jobs with how far they got and what they found so far. + jobs: Vec<(Job, usize, Vec<(Tag, usize, usize)>)>, +} + +fn hash_of(v: &impl Hash) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + v.hash(&mut h); + h.finish() +} + +impl Checker { + fn push(&mut self, job: Job) { + self.jobs.retain(|(j, _, _)| j.kind != job.kind); + self.jobs.push((job, 0, vec![])); + } + + fn idle(&self) -> bool { + self.jobs.is_empty() + } + + /// Check until `budget_ms` is spent (or everything is done); finished jobs. + fn step(&mut self, budget_ms: f64) -> Vec<(Done, Option)> { + let t0 = now_ms(); + let mut out = vec![]; + while let Some((job, at, found)) = self.jobs.first_mut() { + if self.user != Some(job.key.user) { + self.cache.clear(); + self.user = Some(job.key.user); + } + while *at < job.paras.len() { + let (tag, p) = &job.paras[*at]; + let k = hash_of(&(serde_json::to_string(&p.runs).unwrap_or_default(), &job.default_lang)); + let r = self + .cache + .entry(k) + .or_insert_with(|| deckcraft_engine::spell::misspelt(p, &job.default_lang, &[]).into_iter().map(|w| (w.start, w.end)).collect()); + found.extend(r.iter().map(|(a, b)| (*tag, *a, *b))); + *at += 1; + if now_ms() - t0 > budget_ms { + return out; + } + } + let (job, _, ranges) = self.jobs.remove(0); + out.push((Done { kind: job.kind, key: job.key, ranges }, job.repaint)); + } + if self.cache.len() > 20_000 { + self.cache.clear(); + } + out + } +} + +fn worker(rx: Receiver, tx: Sender) { + let mut c = Checker::default(); + loop { + if c.idle() { + match rx.recv() { + Ok(j) => c.push(j), + Err(_) => return, + } + } + while let Ok(j) = rx.try_recv() { + c.push(j); + } + // In slices, so a newer job can replace one being checked. + for (d, repaint) in c.step(20.0) { + if tx.send(d).is_err() { + return; + } + if let Some(r) = repaint { + r.request_repaint(); + } + } + } +} + +fn default_lang(st: &DocState) -> String { + for sl in &st.doc.slides { + let mut found = None; + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if found.is_none() + && let Some(t) = &sh.text + { + found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()); + } + }); + if let Some(l) = found { + return l; + } + } + "en-US".into() +} + +/// A word under a squiggle: `(tag, start, end)`. +type Mark = (Tag, usize, usize); + +#[derive(Default)] +pub struct Squiggles { + tx: Option>, + rx: Option>, + /// No worker thread (the web build, or forced for tests): check on the UI thread. + inline: Option, + /// Check on the UI thread even where threads exist (tests of the web path). + pub force_inline: bool, + pending: [Option; 2], + done: [Option; 2], + /// Underlines in slide coordinates for the slide's `done`, and the key they were laid out for. + lines: Vec<(Mark, Point, Point)>, + lines_for: Option, + /// Off (tests or the setting). + pub disabled: bool, + /// The misspelt word a context menu is open on. + pub menu_word: Option, + /// The same for the notes pane (`Tag::Notes`, `start` in the line). + pub notes_menu: Option, + /// Words skipped with Ignore All this session (lower-case). + ignored: Vec, +} + +/// A misspelt word under the pointer, with corrections. +#[derive(Clone, Debug)] +pub struct MenuWord { + pub tag: Tag, + pub start: usize, + pub word: String, + pub suggestions: Vec, + /// A repeated word: Delete Repeated Word removes characters `from..start + word`. + pub delete_from: Option, +} + +/// Proofing languages offered by Review › Language: (name, tag). +pub const LANGS: &[(&str, &str)] = &[ + ("English (United States)", "en-US"), + ("English (United Kingdom)", "en-GB"), + ("English (Australia)", "en-AU"), + ("English (Canada)", "en-CA"), + ("Español (España)", "es-ES"), + ("Español (México)", "es-MX"), + ("Français (France)", "fr-FR"), + ("Français (Canada)", "fr-CA"), + ("Deutsch (Deutschland)", "de-DE"), + ("Deutsch (Schweiz)", "de-CH"), + ("Italiano", "it-IT"), + ("Nederlands", "nl-NL"), + ("Português (Brasil)", "pt-BR"), + ("Português (Portugal)", "pt-PT"), + ("Polski", "pl-PL"), + ("Svenska", "sv-SE"), + ("Dansk", "da-DK"), + ("Norsk bokmål", "nb-NO"), + ("Suomi", "fi-FI"), + ("Čeština", "cs-CZ"), + ("Українська", "uk-UA"), + ("Русский", "ru-RU"), + ("Беларуская", "be-BY"), + ("Türkçe", "tr-TR"), + ("Ελληνικά", "el-GR"), + ("日本語", "ja-JP"), + ("中文(中国)", "zh-CN"), + ("한국어", "ko-KR"), + ("العربية", "ar-SA"), + ("עברית", "he-IL"), +]; + +/// The display name of a language tag (the tag itself when it isn't in [`LANGS`]). +pub fn lang_name(tag: &str) -> String { + LANGS.iter().find(|(_, t)| t.eq_ignore_ascii_case(tag)).map_or_else(|| tag.to_string(), |(n, _)| n.to_string()) +} + +/// The flagged word (misspelt or repeated) of `pp` starting at `start`, with what the menu offers. +pub fn menu_word(pp: &Paragraph, default: &str, tag: Tag, start: usize) -> Option { + let w = deckcraft_engine::spell::misspelt(pp, default, &[]).into_iter().find(|w| w.start == start)?; + let suggestions = + if w.repeated.is_some() { vec![] } else { deckcraft_engine::spell::dictionary(&w.lang).map(|d| d.suggest(&w.text, 5)).unwrap_or_default() }; + Some(MenuWord { tag, start, word: w.text, suggestions, delete_from: w.repeated }) +} + +fn slot(k: Kind) -> usize { + match k { + Kind::Slide => 0, + Kind::Notes => 1, + } +} + +impl Squiggles { + fn start(&mut self) -> bool { + if self.tx.is_some() || self.inline.is_some() { + return true; + } + if !cfg!(target_arch = "wasm32") && !self.force_inline { + let (jtx, jrx) = channel(); + let (dtx, drx) = channel(); + if std::thread::Builder::new().name("spelling".into()).spawn(move || worker(jrx, dtx)).is_ok() { + self.tx = Some(jtx); + self.rx = Some(drx); + return true; + } + } + self.inline = Some(Checker::default()); + true + } + + /// Take in finished checks (and, on the UI thread, spend this frame's budget). + fn receive(&mut self, budget_ms: f64) { + let mut got = vec![]; + if let Some(rx) = &self.rx { + while let Ok(d) = rx.try_recv() { + got.push(d); + } + } + if let Some(c) = self.inline.as_mut() { + got.extend(c.step(budget_ms).into_iter().map(|(d, _)| d)); + } + for d in got { + let i = slot(d.kind); + if self.pending[i] == Some(d.key) { + self.pending[i] = None; + } + self.done[i] = Some(d); + } + } + + fn ask(&mut self, kind: Kind, key: Key, default_lang: String, paras: impl FnOnce() -> Vec<(Tag, Paragraph)>, repaint: Option<&egui::Context>) { + let i = slot(kind); + if self.done[i].as_ref().is_some_and(|d| d.key == key) || self.pending[i] == Some(key) { + return; + } + let job = Job { kind, key, default_lang, paras: paras(), repaint: repaint.cloned() }; + if let Some(c) = self.inline.as_mut() { + c.push(job); + self.pending[i] = Some(key); + if let Some(r) = repaint { + r.request_repaint(); + } + } else if self.tx.as_ref().is_some_and(|tx| tx.send(job).is_ok()) { + self.pending[i] = Some(key); + } + } + + fn is_ignored(&self, word: &str) -> bool { + !self.ignored.is_empty() && self.ignored.contains(&word.to_lowercase()) + } + + /// Ask for a check of the current slide if it changed, take in finished checks, and return the + /// underlines to draw (slide coordinates). + pub fn update(&mut self, st: &DocState, repaint: Option<&egui::Context>) -> Vec<(Point, Point)> { + if self.disabled || !self.start() { + return vec![]; + } + self.receive(FRAME_BUDGET_MS); + let key = + Key { of: hash_of(&(Arc::as_ptr(&st.doc) as usize, st.revision, st.selection.slide)), user: deckcraft_engine::spell::user_generation() }; + let paras = || { + let mut paras = vec![]; + if let Some(sl) = st.doc.slides.get(st.selection.slide) { + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if let Some(t) = &sh.text { + for (i, p) in t.paragraphs.iter().enumerate() { + paras.push((Tag::Shape { id: sh.id, para: i }, p.clone())); + } + } + if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { + for (r, row) in tb.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate() { + if cell.is_covered() { + continue; + } + for (i, p) in cell.text.paragraphs.iter().enumerate() { + paras.push((Tag::Cell { id: sh.id, row: r, col: c, para: i }, p.clone())); + } + } + } + } + }); + } + paras + }; + self.ask(Kind::Slide, key, default_lang(st), paras, repaint); + let Some(d) = self.done[0].as_ref().filter(|d| d.key == key) else { return vec![] }; + if self.lines_for != Some(key) { + self.lines.clear(); + for &(tag, a, b) in &d.ranges { + let under = match tag { + Tag::Shape { id, para } => deckcraft_engine::cmd::text::range_underlines(st, id, &[(para, a, b)]), + Tag::Cell { id, row, col, para } => deckcraft_engine::cmd::text::cell_range_underlines(st, id, row, col, &[(para, a, b)]), + Tag::Notes { .. } => vec![], + }; + self.lines.extend(under.into_iter().map(|(p, q)| ((tag, a, b), p, q))); + } + self.lines_for = Some(key); + } + // Not under the word the caret is in. + let typing = st.selection.text.as_ref().filter(|t| !t.notes).map(|t| (t.shape, t.cell, t.caret)); + let under_caret = |tag: &Tag, a: usize, b: usize| { + typing.is_some_and(|(s, cell, (cp, cc))| { + let here = match *tag { + Tag::Shape { id, para } => cell.is_none() && id == s && para == cp, + Tag::Cell { id, row, col, para } => cell == Some((row, col)) && id == s && para == cp, + Tag::Notes { .. } => false, + }; + here && cc >= a && cc <= b + }) + }; + self.lines + .iter() + .filter(|((tag, a, b), _, _)| !under_caret(tag, *a, *b)) + .filter(|((tag, a, b), _, _)| self.ignored.is_empty() || !word_of(st, *tag, *a, *b).is_some_and(|w| self.is_ignored(&w))) + .map(|(_, a, b)| (*a, *b)) + .collect() + } + + /// The misspelt character ranges of the notes text being shown (`caret` is the cursor's + /// character index, whose word isn't marked); empty until the check is in. + pub fn notes(&mut self, text: &str, lang: &str, caret: Option, repaint: Option<&egui::Context>) -> Vec<(usize, usize)> { + if self.disabled || !self.start() { + return vec![]; + } + self.receive(FRAME_BUDGET_MS); + let key = Key { of: hash_of(&(text, lang)), user: deckcraft_engine::spell::user_generation() }; + let paras = || { + let mut out = vec![]; + let mut start = 0; + for line in text.split('\n') { + out.push((Tag::Notes { start }, Paragraph::new(line))); + start += line.chars().count() + 1; + } + out + }; + self.ask(Kind::Notes, key, lang.to_string(), paras, repaint); + let Some(d) = self.done[1].as_ref().filter(|d| d.key == key) else { return vec![] }; + let chars: Vec = text.chars().collect(); + d.ranges + .iter() + .filter_map(|&(tag, a, b)| match tag { + Tag::Notes { start } => Some((start + a, start + b)), + _ => None, + }) + .filter(|(a, b)| !caret.is_some_and(|c| c >= *a && c <= *b)) + .filter(|(a, b)| { + self.ignored.is_empty() || !self.is_ignored(&chars.get(*a..*b).map(|c| c.iter().collect::()).unwrap_or_default()) + }) + .collect() + } + + /// Skip `word` everywhere for the rest of the session. + pub fn ignore(&mut self, word: &str) { + self.ignored.push(word.to_lowercase()); + } + + /// The squiggled word at slide point `p` (within `tol` points of its underline). + pub fn word_at(&self, st: &DocState, p: Point, tol: f64) -> Option { + let ((tag, start, end), _, _) = *self.lines.iter().find(|(_, a, b)| { + let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); + // From the line's top (a word is about a line high above its underline) to just below it. + p.x >= x0 - tol && p.x <= x1 + tol && p.y <= a.y.max(b.y) + tol && p.y >= a.y.min(b.y) - 24.0 + })?; + let _ = end; + menu_word(¶graph_of(st, tag)?, &default_lang(st), tag, start) + } + + /// Wait (tests) until the slide's check for `st` is in. + pub fn settle(&mut self, st: &DocState) -> Vec<(Point, Point)> { + for _ in 0..2000 { + let v = self.update(st, None); + if self.pending[0].is_none() && self.done[0].is_some() { + return v; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + vec![] + } + + /// Wait (tests) until the notes check for `text` is in. + pub fn settle_notes(&mut self, text: &str, lang: &str) -> Vec<(usize, usize)> { + for _ in 0..2000 { + let v = self.notes(text, lang, None, None); + if self.pending[1].is_none() && self.done[1].is_some() { + return v; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + vec![] + } +} + +fn paragraph_of(st: &DocState, tag: Tag) -> Option { + let sl = st.doc.slides.get(st.selection.slide)?; + match tag { + Tag::Shape { id, para } => deckcraft_model::find_shape(&sl.shapes, id)?.text.as_ref()?.paragraphs.get(para).cloned(), + Tag::Cell { id, row, col, para } => match &deckcraft_model::find_shape(&sl.shapes, id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).cloned(), + _ => None, + }, + Tag::Notes { .. } => None, + } +} + +fn word_of(st: &DocState, tag: Tag, a: usize, b: usize) -> Option { + Some(paragraph_of(st, tag)?.text().chars().skip(a).take(b - a).collect()) +} + +/// A red wavy line from `a` to `b` (screen points). +pub fn paint(painter: &egui::Painter, a: egui::Pos2, b: egui::Pos2, scale: f32) { + let d = b - a; + let len = d.length(); + if len < 1.0 { + return; + } + let (u, n) = (d / len, egui::vec2(-d.y, d.x) / len); + let step = (2.0 * scale).clamp(1.5, 3.0); + let amp = step * 0.75; + let mut pts = vec![]; + let mut t = 0.0; + let mut up = false; + while t <= len { + pts.push(a + u * t + n * if up { -amp } else { amp }); + up = !up; + t += step; + } + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, egui::Color32::from_rgb(0xE0, 0x20, 0x20)))); +} diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 8e915b71..d5d03cdf 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -20,15 +20,17 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { }; let p = ui.painter(); p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); - p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); + // The selection's proofing language; a click opens Review › Language. + let (lang, no_proof) = crate::dialogs::selected_language(app); + let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" }; + let lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); + if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("language")); + } // Transient status message. - if let Some((msg, at)) = app.status.clone() { - if crate::now_ms() - at < 6000.0 { - p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } else { - app.status = None; - } + if let Some(msg) = app.fresh_status() { + p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } let mut right = ui.new_child( egui::UiBuilder::new() @@ -39,10 +41,10 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() { app.ui.zoom = None; } - let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round(); + let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); if lbl.clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("zoom")); + let _ = app.run("view.zoomDialog", json!({})); } if small_icon(&mut right, Icon::ZoomIn, "Zoom In", false).clicked() { let _ = app.run("view.zoomIn", json!({})); @@ -108,6 +110,11 @@ fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { resp } +/// The language of a slide's notes: the first notes run that has one. +fn notes_lang(s: &deckcraft_model::Slide) -> Option { + s.notes.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()) +} + /// The speaker notes editor under the slide. pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -115,9 +122,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { if st.selection.target != deckcraft_engine::Target::Slides { return; } - let idx = st.selection.slide; - let uid = st.uid; - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); + let (idx, uid) = (st.selection.slide, st.uid); + // Save an edited draft once the field has lost focus or another slide is shown (before + // anything reloads it), to the slide it was written on. + let id = egui::Id::new("notes_edit"); + let focused = ui.ctx().memory(|m| m.has_focus(id)); + if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) { + app.notes_buf.2.edited = false; + if app.notes_buf.0 == uid { + let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1})); + } + } + let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default(); egui::Panel::bottom("notes") .resizable(true) .default_size(app.ui.notes_height) @@ -128,35 +144,107 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let rect = ui.max_rect(); ui.painter() .line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator)); - // Keep a local buffer per slide; commit when it changes and the field loses focus. - let id = egui::Id::new("notes_edit"); - let focused = ui.ctx().memory(|m| m.has_focus(id)); - // Focus can leave in the frame before the field is drawn (Escape, a click on a - // thumbnail): what was typed is still committed, to the slide it was typed for. - let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id)); - if app.notes_buf.0 != uid || app.notes_buf.1 != idx { - let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone())); - let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text()); - if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) { - let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx})); + // Keep a local draft per slide, following the notes while the field isn't focused. + if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) { + app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es)); + } + let mut draft = app.notes_buf.2.clone(); + let look = crate::notes::Look { size: 13.0, color: None }; + // Long notes scroll inside the pane instead of spilling over the slide (#221). + let out = egui::ScrollArea::vertical() + .max_height(ui.available_height()) + .auto_shrink([false, false]) + .show(ui, |ui| { + crate::notes::editor(ui, &mut draft, &look, |e| { + e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) + }) + }) + .inner; + let r = out.response.response.clone(); + let mut buf = draft.text.clone(); + // Spelling squiggles under misspelt words of the notes. + let lang = app.session.active().and_then(|d| d.doc.slides.get(idx)).and_then(|s| notes_lang(s)).unwrap_or_else(|| "en-US".into()); + let caret = out.cursor_range.map(|c| c.primary.index.0); + for (a, b) in app.squiggles.notes(&buf, &lang, caret, Some(ui.ctx())) { + let (ra, rb) = (out.galley.pos_from_cursor(egui::text::CCursor::new(a)), out.galley.pos_from_cursor(egui::text::CCursor::new(b))); + // One underline per row the word is on (a word doesn't wrap, so normally one). + if (ra.bottom() - rb.bottom()).abs() < 1.0 { + let y = ra.bottom() - 1.0; + let o = out.galley_pos.to_vec2(); + crate::squiggles::paint(ui.painter(), egui::pos2(ra.left(), y) + o, egui::pos2(rb.left(), y) + o, 1.0); } } - if !focused && !was_focused && app.notes_buf.2 != current { - app.notes_buf.2 = current.clone(); + // Right-click on a flagged word: its corrections, Ignore All, Add to Dictionary. + if r.secondary_clicked() + && let Some(pos) = r.interact_pointer_pos() + { + let at = out.galley.cursor_from_pos(pos - out.galley_pos).index.0; + let hit = app.squiggles.notes(&buf, &lang, None, None).into_iter().find(|(a, b)| at >= *a && at <= *b); + app.squiggles.notes_menu = hit.and_then(|(a, _)| { + let before: String = buf.chars().take(a).collect(); + let line_start = before.rfind('\n').map_or(0, |i| before[..i].chars().count() + 1); + let line = buf.chars().skip(line_start).take_while(|c| *c != '\n').collect::(); + crate::squiggles::menu_word( + &deckcraft_model::Paragraph::new(&line), + &lang, + crate::squiggles::Tag::Notes { start: line_start }, + a - line_start, + ) + }); } - let mut buf = app.notes_buf.2.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(id) - .hint_text("Click to add notes") - .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) - .desired_rows(2) - .font(theme::font(13.0)), - ); - app.notes_buf.2 = buf.clone(); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + let mut changed: Option = None; + if app.squiggles.notes_menu.is_some() { + r.context_menu(|ui| { + let Some(w) = app.squiggles.notes_menu.clone() else { return }; + let crate::squiggles::Tag::Notes { start: ls } = w.tag else { return }; + let chars: Vec = buf.chars().collect(); + let end = ls + w.start + w.word.chars().count(); + let splice = |from: usize, to: &str| -> String { + chars[..from.min(chars.len())] + .iter() + .chain(to.chars().collect::>().iter()) + .chain(chars[end.min(chars.len())..].iter()) + .collect() + }; + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + changed = Some(splice(ls + from, "")); + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + changed = Some(splice(ls + w.start, to)); + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + ui.close(); + } + if w.delete_from.is_none() && ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + ui.close(); + } + }); } + if let Some(new) = changed { + // A correction is typed into the formatted draft, then saved. + buf = new; + app.squiggles.notes_menu = None; + draft.text = buf.clone(); + deckcraft_model::edit::replace_text(&mut draft.body, &buf); + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); + } + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); + } + app.notes_buf.2 = draft; }); } diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs new file mode 100644 index 00000000..69dbf523 --- /dev/null +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -0,0 +1,69 @@ +//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many +//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen, +//! thumbnails, master views and presenter view of a very tall slide. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ) +} + +/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of +/// thousands of pixels high at their usual widths. +fn tall_deck() -> Session { + let mut s = Session::with_new(); + s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s +} + +#[test] +fn a_slide_show_on_a_screen_wider_than_the_texture_limit() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut h = harness(s, egui::vec2(2600.0, 1500.0)); + h.run_steps(2); + h.state_mut().run("show.start", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +#[test] +fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] { + let _ = h.state_mut().run(cmd, p); + h.run_steps(3); + } + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +/// Presenter view of a portrait slide: the "Next" preview keeps the slide's shape inside its +/// column. It took the column's full width and 56 times that in height, a white slab over the +/// notes and past the window. +#[test] +fn presenter_view_keeps_a_portrait_next_slide_in_its_column() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + let img = h.render().unwrap(); + // Right of the Next column's narrow preview, below where a landscape one would end. + let px = img.get_pixel(1000, 600).0; + assert!(px[0] < 128 && px[1] < 128 && px[2] < 128, "the Next preview spills over the column: {px:?}"); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..9730f4d6 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use deckcraft_geom::Rect; use deckcraft_model::{Master, Presentation, ShapeId, Slide}; use deckcraft_render::{Image, Region, RenderOpts}; -use egui::{ColorImage, TextureHandle, TextureOptions}; +use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode}; #[derive(Clone, PartialEq)] struct Key { @@ -21,6 +21,7 @@ struct Key { extra: u64, /// The pasteboard region the slide's drawing calls for (canvas only). want: Region, + skip_text: Option, } struct Entry { @@ -46,10 +47,91 @@ pub struct Textures { /// Canvas renders so far (tests, performance checks). pub canvas_renders: u64, thumbs: HashMap, + /// SmartArt gallery pictures by layout, style and colours (and, for hover previews, the + /// outline's hash). + smartart: HashMap, clock: u64, pub last_render_ms: f64, } +impl Textures { + /// SmartArt gallery pictures cached so far (tests). + pub fn smartart_cached(&self) -> usize { + self.smartart.len() + } + + /// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected + /// graphic's own outline), cached; drawn with the active presentation's theme. + pub fn smartart( + &mut self, + ctx: &egui::Context, + p: &deckcraft_model::Presentation, + slide: usize, + kind: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, + width_px: u32, + ) -> Option { + let mut key = format!("{kind}|{style}|{colors}|{width_px}"); + if let Some(n) = nodes { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for x in n { + (x.text.as_str(), x.level).hash(&mut h); + } + key.push_str(&format!("|{:x}", h.finish())); + } + if let Some(t) = self.smartart.get(&key) { + return Some(t.clone()); + } + let mut s = p.slides.get(slide).or_else(|| p.slides.first())?.as_ref().clone(); + let g = deckcraft_engine::cmd::smartart::preview(kind, style, colors, nodes); + let id = g.id; + s.shapes = vec![g]; + let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?; + let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false)); + if self.smartart.len() > 300 { + self.smartart.clear(); + } + self.smartart.insert(key, tex.clone()); + Some(tex) + } +} + +/// Snap `rect` onto the physical pixel grid. +/// +/// The editor draws the slide as a texture of device pixels. A rect that starts or ends +/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two +/// texels and small text looks soft. On a Retina display that offset is half a point, which +/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so +/// the same blend is not visible there (#184). +pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect { + if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() { + return rect; + } + let snap = |v: f32| { + let p = v * ppp; + if p.is_finite() { p.round() / ppp } else { v } + }; + let min = egui::pos2(snap(rect.min.x), snap(rect.min.y)); + let step = 1.0 / ppp; + let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step)); + egui::Rect::from_min_max(min, max) +} + +/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a +/// fraction of a pixel from blurring the glyph; minification still blends when many texels +/// share a screen pixel. +pub fn editor_texture_options() -> TextureOptions { + TextureOptions { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + } +} + pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; @@ -67,6 +149,77 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { ColorImage::new([w, h], px) } +/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on +/// a bigger texture. +pub fn max_side(ctx: &egui::Context) -> u32 { + ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32 +} + +/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0). +/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a +/// picture the GPU can't hold. +pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) { + let (w, h, max) = (w.max(1), h.max(1), max.max(1)); + let big = w.max(h); + if big <= max { + return (w, h); + } + let k = max as f64 / big as f64; + (((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max)) +} + +/// `img` shrunk by whole-pixel box averaging until both sides fit `max`. +pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage { + let [w, h] = img.size; + let max = max.max(1) as usize; + if w <= max && h <= max { + return img; + } + let f = w.max(h).div_ceil(max); + let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1)); + let mut px = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0u32; 4]; + let mut n = 0u32; + for sy in y * f..((y + 1) * f).min(h) { + for sx in x * f..((x + 1) * f).min(w) { + if let Some(c) = img.pixels.get(sy * w + sx) { + for (a, v) in acc.iter_mut().zip(c.to_array()) { + *a += v as u32; + } + n += 1; + } + } + } + let n = n.max(1); + px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8)); + } + } + ColorImage::new([nw, nh], px) +} + +/// Upload a texture, shrunk to the renderer's limit if need be. +pub fn upload(ctx: &egui::Context, name: impl Into, img: ColorImage) -> TextureHandle { + ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR) +} + +/// Replace a texture's picture, shrunk to the renderer's limit if need be. +pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { + tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR); +} + +/// [`upload`] for editor slide pictures, drawn on the device pixel grid with +/// [`editor_texture_options`] (#184). +pub fn upload_editor(ctx: &egui::Context, name: impl Into, img: ColorImage) -> TextureHandle { + ctx.load_texture(name, shrink_to(img, max_side(ctx)), editor_texture_options()) +} + +/// [`replace`] for editor slide pictures (#184). +pub fn replace_editor(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { + tex.set(shrink_to(img, max_side(ctx)), editor_texture_options()); +} + fn master_of(p: &Presentation, slide: &Slide) -> Option> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } @@ -79,6 +232,14 @@ fn same(a: &Option>, b: &Option>) -> bool { } } +/// Mix the preview playhead into the canvas cache key. Zero means "no preview", so a playhead of +/// 0s still misses the at-rest texture (entrances start hidden). +fn anim_key(anim: Option<(&deckcraft_anim::Timeline, f64)>) -> u64 { + let Some((_, t)) = anim else { return 0 }; + let ms = if t.is_finite() { (t * 1000.0).clamp(0.0, 1.0e12) as u64 } else { 0 }; + ms.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15) +} + fn threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } } @@ -87,7 +248,8 @@ impl Textures { /// The canvas texture for slide `index`, the slide being `size` pixels, and the region it /// covers: the slide, grown over the pasteboard to show what reaches off the slide within /// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the - /// GIF frames. + /// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed + /// into). #[allow(clippy::too_many_arguments)] pub fn slide( &mut self, @@ -100,6 +262,8 @@ impl Textures { extra: u64, grayscale: bool, gif_times: &[(ShapeId, f64)], + skip_text: Option, + anim: Option<(&deckcraft_anim::Timeline, f64)>, ) -> Option<(TextureHandle, Region)> { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); @@ -116,14 +280,17 @@ impl Textures { Some((i, d)) if i == index => estimate.union(d), _ => estimate, }; - let want = deckcraft_render::pasteboard_region(size, scale, reach, visible); + // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. + let max_side = max_side(ctx); + let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size, edit, - extra: extra ^ grayscale as u64, + extra: extra ^ grayscale as u64 ^ anim_key(anim), want, + skip_text, }; if let Some(e) = &self.canvas && e.key == key @@ -132,8 +299,19 @@ impl Textures { } let t0 = crate::now_ms(); let render = |region: Region| { - let opts = - RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() }; + let state_of = |id: ShapeId| anim.map(|(tl, time)| crate::preview::shape_state_at(tl, id, time)).unwrap_or_default(); + let state: Option<&dyn Fn(ShapeId) -> deckcraft_render::ShapeState> = if anim.is_some() { Some(&state_of) } else { None }; + let opts = RenderOpts { + scale, + edit, + gif_times, + threads: threads(), + offset: region.offset(), + size: Some(region.size()), + state, + skip_text, + ..Default::default() + }; deckcraft_render::render_slide_drawn(p, index, &opts) }; let (mut img, drawn) = render(want); @@ -142,7 +320,8 @@ impl Textures { // Text spilling out of its box can reach further than the boxes said. Once drawn, the // region it calls for is known (and is next frame's key): render again only if it shows // more than this render covered. - let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key }; + let key = + Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key }; if !region.contains(&key.want) { region = key.want; img = render(region).0; @@ -153,15 +332,20 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + replace_editor(ctx, &mut e.tex, ci); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + None => upload_editor(ctx, "slide-canvas", ci), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) } + /// Cached thumbnails (bounded; soak tests check it). + pub fn thumb_count(&self) -> usize { + self.thumbs.len() + } + /// Force the canvas to re-render next time (fonts loaded, theme changed in place…). pub fn invalidate(&mut self) { self.canvas = None; @@ -183,6 +367,7 @@ impl Textures { edit: false, extra: 0, want: Region::slide(width_px, h), + skip_text: None, }; self.clock += 1; let clock = self.clock; @@ -197,9 +382,10 @@ impl Textures { return self.thumbs.get(&key.slide).map(|e| e.tex.clone()); } *budget -= 1; - let scale = width_px as f64 / p.slide_size.width.max(1.0); - let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let (tw, th) = fit_size((width_px, h), max_side(ctx)); + let scale = tw as f64 / p.slide_size.width.max(1.0); + let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() }); + let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false)); // Drop the old entry for this slide address; entries for slides that no longer exist age out. let region = key.want; self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); @@ -213,3 +399,88 @@ impl Textures { Some(tex) } } + +#[cfg(test)] +mod tests { + use super::device_pixel_rect; + use egui::{Rect, pos2, vec2}; + + fn on_grid(v: f32, ppp: f32) -> bool { + let px = v * ppp; + (px - px.round()).abs() < 1e-3 + } + + #[test] + fn a_retina_slide_rect_lands_on_device_pixels() { + let ppp = 2.0; + let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4)); + let s = device_pixel_rect(r, ppp); + assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp)); + assert!(s.width() > 0.0 && s.height() > 0.0); + // One texture pixel per device pixel: the snapped size is an integer pixel count. + let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32); + assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3); + assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3); + let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0)); + assert_eq!(device_pixel_rect(aligned, ppp), aligned); + } + + #[test] + fn a_bad_scale_does_not_move_the_rect() { + let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0)); + assert_eq!(device_pixel_rect(r, 0.0), r); + assert_eq!(device_pixel_rect(r, f32::NAN), r); + } +} + +/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits. +pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region { + fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) { + if *len > max { + let hi = *start as i64 + (*len - max) as i64; + *start = (vis as i64).clamp(*start as i64, hi) as i32; + *len = max; + } + } + axis(&mut r.x, &mut r.w, visible.x, max); + axis(&mut r.y, &mut r.h, visible.y, max); + r +} + +#[cfg(test)] +mod fit_tests { + use super::*; + + #[test] + fn canvas_textures_stay_within_the_texture_limit() { + let r = Region { x: -100, y: -50, w: 5000, h: 1000 }; + let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000)); + // Visible past the far end: the cut is pinned there. + let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); + assert_eq!(fit_texture(r, r, 8192), r); + } + + #[test] + fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() { + assert_eq!(fit_size((195, 10920), 2048), (36, 2048)); + assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024)); + assert_eq!(fit_size((800, 600), 2048), (800, 600)); + assert_eq!(fit_size((0, 0), 0), (1, 1)); + // A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1. + let img = ColorImage::new( + [5000, 3], + (0..3) + .flat_map(|y| (0..5000).map(move |x| (x, y))) + .map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE }) + .collect(), + ); + let small = shrink_to(img, 2048); + assert_eq!(small.size, [1667, 1]); + assert_eq!(small.pixels[0], egui::Color32::RED); + assert_eq!(small.pixels[1666], egui::Color32::BLUE); + let ok = ColorImage::filled([10, 10], egui::Color32::WHITE); + assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size); + } +} diff --git a/crates/ui-egui/src/theme_colors_tests.rs b/crates/ui-egui/src/theme_colors_tests.rs new file mode 100644 index 00000000..d7dba9bb --- /dev/null +++ b/crates/ui-egui/src/theme_colors_tests.rs @@ -0,0 +1,136 @@ +//! Design › Colors › Customize Colors… opens Create New Theme Colors and saves in one step. + +use deckcraft_color::SchemeSlot; +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn scheme(h: &Harness<'_, SlideApp>) -> deckcraft_color::ColorScheme { + h.state().session.active().unwrap().doc.masters[0].theme.colors.clone() +} + +fn undo_len(h: &Harness<'_, SlideApp>) -> usize { + h.state().session.active().unwrap().history.undo.len() +} + +fn open_dialog(h: &mut Harness<'_, SlideApp>) { + h.get_by_label("Colors").click(); + h.run_steps(2); + h.get_by_label("Customize Colors…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeColors")); +} + +#[test] +fn customize_colors_opens_from_the_design_tab() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + assert_eq!(h.get_by_label("Scheme name").value().as_deref(), Some(before.name.as_str())); + let accent = format!("#{}", before.get(SchemeSlot::Accent1).hex()); + assert_eq!(h.get_by_label("Accent 1 hex").value().as_deref(), Some(accent.as_str())); + let _ = h.get_by_label("Text/Background - Dark 1 hex"); + let _ = h.get_by_label("Text/Background - Light 1 hex"); + let _ = h.get_by_label("Followed Hyperlink hex"); + let _ = h.get_by_label("Hyperlink hex"); + let _ = h.get_by_label("Save"); + let _ = h.get_by_label("Cancel"); + let save = h.get_by_label("Save").rect(); + let win = h.get_by_label("Create New Theme Colors").rect(); + assert!(win.contains(save.center()), "Save {save:?} is outside the dialog window {win:?}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n, "saving the current colours is not an undo step"); +} + +#[test] +fn save_applies_the_name_and_one_colour() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("schemeName".into(), "Talk".into()); + d.fields.insert("accent1".into(), "#ABCDEF".into()); + } + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + let after = scheme(&h); + assert_eq!(after.name, "Talk"); + assert_eq!(after.get(SchemeSlot::Accent1).hex(), "ABCDEF"); + assert_eq!(after.get(SchemeSlot::Dk1), before.get(SchemeSlot::Dk1)); + assert_eq!(undo_len(&h), n + 1); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Colors"); +} + +#[test] +fn a_bad_hex_keeps_the_dialog_open() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("accent1".into(), "zzzzzz".into()); + h.get_by_label("Save").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeColors")); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); + let status = h.state().status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("Accent 1"), "{status}"); +} + +#[test] +fn cancel_closes_without_editing() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("accent1".into(), "#112233".into()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); +} + +#[test] +fn typing_a_hex_replaces_accent_1() { + let mut h = harness(); + let n = undo_len(&h); + open_dialog(&mut h); + h.get_by_label("Accent 1 hex").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.get_by_label("Accent 1 hex").type_text("#11AA22"); + h.run_steps(1); + let shown = h.get_by_label("Accent 1 hex").value().unwrap_or_default(); + assert_eq!(shown, "#11AA22", "the hex field should replace its text, got {shown}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + assert_eq!(scheme(&h).name, "DeckCraft"); + assert_eq!(scheme(&h).get(SchemeSlot::Accent1).hex(), "11AA22"); + assert_eq!(undo_len(&h), n + 1); +} diff --git a/crates/ui-egui/src/theme_fonts_tests.rs b/crates/ui-egui/src/theme_fonts_tests.rs new file mode 100644 index 00000000..adee8b99 --- /dev/null +++ b/crates/ui-egui/src/theme_fonts_tests.rs @@ -0,0 +1,136 @@ +//! Design › Fonts › Customize Fonts… opens Create New Theme Fonts and saves in one step. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn scheme(h: &Harness<'_, SlideApp>) -> deckcraft_model::theme::FontScheme { + h.state().session.active().unwrap().doc.masters[0].theme.fonts.clone() +} + +fn undo_len(h: &Harness<'_, SlideApp>) -> usize { + h.state().session.active().unwrap().history.undo.len() +} + +fn open_dialog(h: &mut Harness<'_, SlideApp>) { + h.get_by_label("Fonts").click(); + h.run_steps(2); + h.get_by_label("Customize Fonts…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeFonts")); +} + +#[test] +fn customize_fonts_opens_from_the_design_tab() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + assert_eq!(h.get_by_label("Scheme name").value().as_deref(), Some(before.name.as_str())); + assert_eq!(h.get_by_label("Heading font").value().as_deref(), Some(before.major.latin.as_str())); + assert_eq!(h.get_by_label("Body font").value().as_deref(), Some(before.minor.latin.as_str())); + let _ = h.get_by_label(&format!("Heading sample {}", before.major.latin)); + let _ = h.get_by_label(&format!("Body sample {}", before.minor.latin)); + let _ = h.get_by_label("Save"); + let _ = h.get_by_label("Cancel"); + let save = h.get_by_label("Save").rect(); + let win = h.get_by_label("Create New Theme Fonts").rect(); + assert!(win.contains(save.center()), "Save {save:?} is outside the dialog window {win:?}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n, "saving the current fonts is not an undo step"); +} + +#[test] +fn save_applies_the_name_and_both_faces() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("schemeName".into(), "Talk".into()); + d.fields.insert("major".into(), "Lato".into()); + d.fields.insert("minor".into(), "Roboto".into()); + } + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + let after = scheme(&h); + assert_eq!(after.name, "Talk"); + assert_eq!(after.major.latin, "Lato"); + assert_eq!(after.minor.latin, "Roboto"); + assert_eq!(after.major.ea, before.major.ea); + assert_eq!(undo_len(&h), n + 1); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Fonts"); +} + +#[test] +fn a_blank_heading_keeps_the_dialog_open() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("major".into(), " ".into()); + h.get_by_label("Save").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeFonts")); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); + let status = h.state().status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("heading"), "{status}"); +} + +#[test] +fn cancel_closes_without_editing() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("major".into(), "Lato".into()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); +} + +#[test] +fn typing_a_name_replaces_the_scheme_name() { + let mut h = harness(); + let n = undo_len(&h); + open_dialog(&mut h); + h.get_by_label("Scheme name").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.get_by_label("Scheme name").type_text("Talk"); + h.run_steps(1); + let shown = h.get_by_label("Scheme name").value().unwrap_or_default(); + assert_eq!(shown, "Talk", "the name field should replace its text, got {shown}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + assert_eq!(scheme(&h).name, "Talk"); + assert_eq!(scheme(&h).major.latin, "Inter"); + assert_eq!(scheme(&h).minor.latin, "Inter"); + assert_eq!(undo_len(&h), n + 1); +} diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs new file mode 100644 index 00000000..985e6401 --- /dev/null +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -0,0 +1,54 @@ +//! Review › Thesaurus driven like a person: caret in a word, open the pane, click a synonym. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn thesaurus_pane_swaps_the_word_at_the_caret() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Review".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Thesaurus").click(); + h.run_steps(3); + assert_eq!(h.state().ui.pane.as_deref(), Some("thesaurus")); + assert_eq!(h.state().thesaurus_query, "Goal"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/thesaurus.png"); + } + h.get_by_label("Objective").click(); + h.run_steps(3); + let text = h + .state() + .session + .active() + .unwrap() + .shapes() + .iter() + .find(|x| x.id.0 as u64 == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.paragraphs[0].text()); + assert_eq!(text.as_deref(), Some("Our Objective is growth.")); + assert_eq!(h.state().thesaurus_query, "Objective", "the pane follows the new word"); + + // Typing a word that isn't listed says so instead of showing nothing. + h.state_mut().thesaurus_query = "zzxq".into(); + h.run_steps(2); + assert!(h.query_by_label_contains("No synonyms").is_some()); +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0..d6c9c02c 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -11,7 +11,11 @@ use crate::theme::{self, Tokens}; pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); - egui::Panel::left("thumbs") + // Handout Master view has one page and no thumbnails, as in PowerPoint. + if app.session.active().is_some_and(|d| d.selection.target.is_page_master()) { + return; + } + let panel = egui::Panel::left("thumbs") .resizable(true) .default_size(app.ui.thumbs_width) .size_range(150.0..=420.0) @@ -28,6 +32,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(20.0); }); }); + // Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it. + if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) { + app.slides_focused = panel.response.rect.contains(pos); + } } fn list(app: &mut SlideApp, ui: &mut Ui) { @@ -86,7 +94,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { }); } let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag()); - let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp); rects.push((i, row)); let p = ui.painter(); let id = doc.slides.get(i).map(|s| s.id); @@ -106,7 +114,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { if has_anim { p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); } - let wpx = (tw * ppp).round() as u32; + let wpx = (img.width() * ppp).round() as u32; match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { Some(tex) => { p.image( @@ -268,22 +276,25 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 }; let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); - let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); - let px = ((tw * ppp) as u32).max(1); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp); + let wpx = ((img.width() * ppp).round() as u32).max(1); + let px = crate::textures::fit_size( + (wpx, (wpx as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let pic = deckcraft_render::render_layout( &doc, mi, li, &deckcraft_render::RenderOpts { - scale: px as f64 / doc.slide_size.width.max(1.0), + scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, - size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + size: Some(px), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload_editor(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false)); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi, diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs new file mode 100644 index 00000000..6e25c696 --- /dev/null +++ b/crates/ui-egui/src/ui_soak_tests.rs @@ -0,0 +1,206 @@ +//! UI soak: the whole window driven by seeded random input (clicks, drags, keys with and without +//! Ctrl/Shift, typing, scrolling) over the sample deck, with invariants every few actions: no +//! panic, every document valid, undo/journal/texture caches bounded, and frame time bounded. +//! +//! `DECKCRAFT_UI_SOAK_STEPS=5000 cargo test -p deckcraft-ui-egui ui_soak_long -- --ignored` +//! runs a long one; `DECKCRAFT_UI_SOAK_SEED` picks the seed. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use deckcraft_engine::Session; +use egui::{Event, Key, Modifiers, MouseWheelUnit, Pos2, vec2}; +use egui_kittest::Harness; + +use crate::{Services, SlideApp}; + +const W: f32 = 1400.0; +const H: f32 = 900.0; +/// Bounds that a long session must stay under. +const MAX_TEXTURES: usize = 1_500; +const MAX_THUMBS: usize = 600; +const MAX_JOURNAL: usize = 10_000; +/// Mean wall time per frame, generous for slow CI machines (the test profile is optimized). +const MAX_MEAN_FRAME: Duration = Duration::from_millis(250); + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn pos(&mut self) -> Pos2 { + Pos2::new(self.below(W as usize) as f32, self.below(H as usize) as f32) + } +} + +const KEYS: &[Key] = &[ + Key::ArrowLeft, + Key::ArrowRight, + Key::ArrowUp, + Key::ArrowDown, + Key::Enter, + Key::Tab, + Key::Backspace, + Key::Delete, + Key::Escape, + Key::Home, + Key::End, + Key::PageUp, + Key::PageDown, + Key::A, + Key::C, + Key::V, + Key::X, + Key::D, + Key::G, + Key::B, + Key::I, + Key::Z, + Key::Y, + Key::M, + Key::F5, +]; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(vec2(W, H)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { + match r.below(9) { + 0..=2 => { + let p = r.pos(); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + format!("click {p:?}") + } + 3 => { + let (a, b) = (r.pos(), r.pos()); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + for i in 1..=4 { + h.hover_at(a + (b - a) * (i as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + format!("drag {a:?} -> {b:?}") + } + 4 | 5 => { + let k = KEYS[r.below(KEYS.len())]; + let m = match r.below(4) { + 0 => Modifiers::COMMAND, + 1 => Modifiers::SHIFT, + _ => Modifiers::NONE, + }; + h.key_press_modifiers(m, k); + format!("key {m:?} {k:?}") + } + 6 => { + h.event(Event::Text("Soak ✓ שלום 漢字 ".into())); + "type".into() + } + 7 => { + let p = r.pos(); + h.hover_at(p); + h.event(Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, if r.below(2) == 0 { 3.0 } else { -3.0 }), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + format!("scroll {p:?}") + } + _ => { + // Undo or redo through the keyboard, the way people hammer it. + let k = if r.below(2) == 0 { Key::Z } else { Key::Y }; + h.key_press_modifiers(Modifiers::COMMAND, k); + format!("ctrl {k:?}") + } + } +} + +fn check(h: &Harness<'_, SlideApp>, seed: u64, step: usize, log: &VecDeque) { + let ctx = || format!("seed {seed} step {step}; last actions:\n{}", log.iter().cloned().collect::>().join("\n")); + let app = h.state(); + for d in app.session.documents() { + let v = d.doc.validate(); + assert!(v.is_empty(), "invalid document {v:?}; {}", ctx()); + assert!(d.history.undo.len() <= d.history.limit.max(1), "undo over limit; {}", ctx()); + assert!(d.selection.slide < d.doc.slides.len().max(1), "selection slide out of range; {}", ctx()); + } + assert!(app.session.journal.len() <= MAX_JOURNAL, "journal {}; {}", app.session.journal.len(), ctx()); + assert!(app.textures.thumb_count() <= MAX_THUMBS, "thumbnails {}; {}", app.textures.thumb_count(), ctx()); + assert_eq!(app.session.panics_caught, 0, "a command panicked (rolled back by the guard); {}", ctx()); + assert_eq!(deckcraft_render::panics_caught(), 0, "a render panic was caught and drawn blank; {}", ctx()); + let tex = h.ctx.tex_manager().read().num_allocated(); + assert!(tex <= MAX_TEXTURES, "{tex} textures allocated; {}", ctx()); +} + +fn soak(seed: u64, steps: usize) { + let mut h = harness(); + let mut r = Rng(seed | 1); + let mut log = VecDeque::new(); + let t0 = Instant::now(); + let mut frames = 0usize; + for step in 0..steps { + log.push_back(act(&mut h, &mut r)); + if log.len() > 20 { + log.pop_front(); + } + h.run_steps(2); + frames += 8; + if step % 10 == 9 { + check(&h, seed, step, &log); + } + // A modal or the slide show can swallow input forever; leave it now and then. + if step % 50 == 49 { + h.key_press(Key::Escape); + h.run_steps(2); + } + } + check(&h, seed, steps, &log); + let edits = h.state().session.journal.len(); + // Random input must reach the editor, not bounce off one modal. + assert!(edits * 20 >= steps, "only {edits} commands ran in {steps} actions; seed {seed}"); + let mean = t0.elapsed() / frames.max(1) as u32; + eprintln!("ui soak seed {seed}: {steps} actions, {edits} commands, ~{frames} frames, mean frame {mean:?}"); + assert!(mean <= MAX_MEAN_FRAME, "mean frame {mean:?} over {MAX_MEAN_FRAME:?}; seed {seed}"); +} + +fn env(name: &str, default: u64) -> u64 { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +#[test] +fn ui_soak_random_input_keeps_the_app_sound() { + for seed in [3, 0xC0FFEE] { + soak(seed, env("DECKCRAFT_UI_SOAK_STEPS", 150) as usize); + } +} + +#[test] +#[ignore = "long UI soak; set DECKCRAFT_UI_SOAK_STEPS"] +fn ui_soak_long() { + soak(env("DECKCRAFT_UI_SOAK_SEED", 0x5EED), env("DECKCRAFT_UI_SOAK_STEPS", 3000) as usize); +} diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs index e1297d9d..f2212a9d 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -14,8 +14,10 @@ use std::sync::Arc; use deckcraft_fonts::{FontDb, FontFace}; -/// Faces larger than this are left out (egui keeps a copy of each face it is given). -const MAX_FACE_BYTES: usize = 48 << 20; +/// Faces larger than this are left out (egui keeps a copy of each face it is given). Large enough +/// for the stock macOS CJK collections the font database picks: `AppleSDGothicNeo.ttc` (about +/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese). +const MAX_FACE_BYTES: usize = 128 << 20; fn accepts_face(byte_len: usize, color: bool) -> bool { !color && byte_len <= MAX_FACE_BYTES diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..6903f1bf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) @@ -117,6 +120,9 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele let arrow_w = if split { 12.0 } else { 0.0 }; let w = 24.0 + tw + arrow_w; let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + if !label.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + } let painter = ui.painter_at(rect); let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); let main_resp = @@ -155,6 +161,8 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) painter.rect_filled(rect, CornerRadius::same(4), c); } icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); + // Named for screen readers (and tests) by its tooltip. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); resp.on_hover_text(tip) } @@ -243,12 +251,25 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, /// (egui menus close on any click inside). Picking a colour closes it (`color_grid` calls /// `ui.close()`); other buttons in it must close it themselves. pub fn color_popup(button: &Response) -> egui::Popup<'_> { - egui::Popup::menu(button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + let mut popup = egui::Popup::menu(button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + // A colour the Eyedropper took for this menu: open it so `color_grid` hands the colour back. + if crate::eyedropper::result_waits_for(&button.ctx, button.id) { + popup = popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + if popup.is_open() { + crate::eyedropper::note_open_menu(&button.ctx, button.id); + } + popup } /// The colour grid popup body: theme colours with tints/shades, standard colours, extra entries. /// Returns the picked colour (`Some(None)` = "No Fill"/"Automatic" when `none_label` is given). pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -> Option> { + // The Eyedropper's colour, handed back as if its swatch had been clicked. + if let Some(c) = crate::eyedropper::take_result(ui.ctx()).and_then(|hex| Rgba::from_hex(&hex)) { + ui.close(); + return Some(Some(ColorRef::rgb(c))); + } let t = Tokens::get(ui.ctx()); let mut out = None; ui.set_min_width(220.0); @@ -343,6 +364,10 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.data_mut(|d| d.insert_temp(id, hex)); + if ui.button("Eyedropper").on_hover_text("Click the slide to take the colour under the pointer").clicked() { + crate::eyedropper::request(ui.ctx()); + ui.close(); + } if out.is_some() { ui.close(); } @@ -423,6 +448,15 @@ pub fn fmt_num(v: f64, suffix: &str) -> String { format!("{s}{suffix}") } +/// The text a kept-open text box shows: the typed draft while the stored value is still what the +/// draft started from, otherwise the stored value (another document, a reorder or an undo changed it). +pub fn text_draft(stored: Option<(String, String)>, current: &str) -> String { + match stored { + Some((base, buf)) if base == current => buf, + _ => current.to_string(), + } +} + /// Section header inside panes. pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -485,4 +519,51 @@ mod tests { assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())))); assert!(harness.query_by_role(Role::TextInput).is_none(), "picking a colour closes the menu"); } + + /// Choosing Eyedropper closes the menu and asks for it; the colour it takes comes back through + /// the same menu, as if its swatch had been clicked. + #[test] + fn the_eyedroppers_colour_comes_back_through_the_menu_that_asked() { + let picked = RefCell::new(None); + let scheme = deckcraft_model::Theme::default().colors; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 500.0)).build_ui(|ui| { + theme::install_fonts(ui.ctx()); + let r = ui.button("Pick"); + color_popup(&r).show(|ui| { + if let Some(c) = color_grid(ui, &scheme, None) { + *picked.borrow_mut() = Some(c); + } + }); + }); + harness.get_by_label("Pick").click(); + harness.run(); + harness.get_by_label("Eyedropper").click(); + harness.run(); + assert!(harness.query_by_label("Eyedropper").is_none(), "choosing it closes the menu"); + assert!(picked.borrow().is_none(), "nothing is picked yet"); + // The app starts the Eyedropper from this request; here the colour is taken at once. + assert!(harness.ctx.data_mut(|d| d.remove_temp::(egui::Id::new("deckcraft.eyedropper.request"))).unwrap_or(false)); + let menu = crate::eyedropper::open_menu(&harness.ctx).expect("the open menu noted its button"); + crate::eyedropper::store_result(&harness.ctx, menu, "00FF00"); + harness.run(); + assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())))); + assert!(harness.query_by_label("Eyedropper").is_none(), "the menu closes again"); + harness.run(); + assert_eq!(picked.borrow().iter().count(), 1, "the colour is handed back once"); + } + + /// #413: a draft is kept while the stored text is unchanged ... + #[test] + fn text_draft_keeps_typing_while_the_stored_text_is_unchanged() { + let stored = Some(("old".to_string(), "old plus typing".to_string())); + assert_eq!(text_draft(stored, "old"), "old plus typing"); + } + + /// ... and dropped when the stored text changes underneath it. + #[test] + fn text_draft_drops_a_stale_draft_when_the_stored_text_changes() { + let stored = Some(("old".to_string(), "old plus typing".to_string())); + assert_eq!(text_draft(stored, "undone"), "undone"); + assert_eq!(text_draft(None, "fresh"), "fresh"); + } } diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index bd9cd1a6..28e9d6d2 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -73,6 +73,8 @@ pub struct Decoder { jobs: VecDeque>, #[cfg(feature = "threads")] pool: Option, + /// Bytes the picture store may take ([`deckcraft_bitstream::budget`]). + memory_budget: u64, } impl Default for Decoder { @@ -104,6 +106,11 @@ impl Decoder { Self::with_threads(default_threads()) } + /// Cap the bytes of decoded pictures (references, current, in flight) this decoder may hold. + pub fn set_memory_budget(&mut self, bytes: u64) { + self.memory_budget = bytes; + } + /// A decoder using up to `threads` worker threads (1 = decode on the calling thread, and /// every picture is returned by the `decode` call that completes it). pub fn with_threads(threads: usize) -> Self { @@ -122,6 +129,7 @@ impl Decoder { last_show_frame: false, stats: DecodeStats::default(), threads: threads.max(1), + memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET, bufs: Arc::default(), draft: false, pending: VecDeque::new(), @@ -256,6 +264,13 @@ impl Decoder { return Ok(()); } ensure!(h.width <= 16384 && h.height <= 16384, "frame size {}x{} too large", h.width, h.height); + // Eight reference slots, the new frame and those still decoding, on the 64-sample grid. + ensure!( + store_fits(h.width, h.height, &h.color, 8 + 1 + self.threads + 2, self.memory_budget), + "frame size {}x{} exceeds the decoder memory budget", + h.width, + h.height + ); let (mi_rows, mi_cols) = (h.mi_rows as usize, h.mi_cols as usize); // compute_image_size semantics (7.2.6). let size = (h.width, h.height); @@ -760,3 +775,41 @@ fn picture_of(f: &Frame, pts: i64, draft: bool) -> Picture { } b.finish() } + +/// Whether `pictures` frames of `width` x `height` (on the 64-sample superblock grid) fit in +/// `budget` bytes. +fn store_fits(width: u32, height: u32, color: &crate::header::ColorConfig, pictures: usize, budget: u64) -> bool { + let shape = deckcraft_bitstream::budget::PictureShape { + width: u64::from(width).div_ceil(64) * 64, + height: u64::from(height).div_ceil(64) * 64, + shift_x: u32::from(color.subsampling_x), + shift_y: u32::from(color.subsampling_y), + chroma_planes: 2, + bytes_per_sample: 2, + }; + shape.fits(pictures as u64, budget) +} + +#[cfg(test)] +mod budget_tests { + use super::*; + use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET; + + fn c420() -> crate::header::ColorConfig { + crate::header::ColorConfig { subsampling_x: true, subsampling_y: true, ..Default::default() } + } + + #[test] + fn the_axis_maximum_is_over_budget_and_4k_is_not() { + assert!(!store_fits(16384, 16384, &c420(), 8 + 1 + 1 + 2, DECODE_MEMORY_BUDGET)); + assert!(store_fits(3840, 2160, &c420(), 8 + 1 + 8 + 2, DECODE_MEMORY_BUDGET)); + } + + #[test] + fn exact_budget_boundary_and_reference_count() { + let one = (64 * 64 + 2 * 32 * 32) * 2; + assert!(store_fits(10, 10, &c420(), 11, 11 * one)); + assert!(!store_fits(10, 10, &c420(), 11, 11 * one - 1)); + assert!(!store_fits(10, 10, &c420(), 12, 11 * one)); + } +} diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..7cd99abf 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -39,9 +39,9 @@ perspective-correct subdivided meshes. |---|---|---| | Effect Options per transition | yes | yes | | Duration, advance on click / after time, Apply To All | yes | yes | -| Transition sounds | yes | **no** (the model has the field; no picker, no playback) | +| Transition sounds | yes | yes: Transitions › Sound offers six built-in sounds (synthesised, no sound files shipped), Other Sound…, [Stop Previous Sound] and Loop Until Next Sound; they play in the show and round-trip | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; unmatched text boxes fade in place; a picture's crop morphs | yes | yes (#119, #118) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -54,7 +54,7 @@ perspective-correct subdivided meshes. | Entrance | ~50 | 24 | Plus, Wedge, Basic Zoom, Center Revolve, Compress, Stretch, Boomerang, Credits, Curve Up, Drop, Flip, Float, Pinwheel, Spiral In, Whip, Ascend, Descend, Unfold | | Emphasis | ~25 | 17 | Brush Color, Bold Reveal, Contrasting Color, Flicker, Grow With Color, Shimmer, Blink | | Exit | ~50 | 14 | Mirrors of the missing entrance effects, plus Blinds, Box, Checkerboard, Circle, Diamond, Peek Out, Strips as exits | -| Motion paths | 64 presets + custom | 6 (Lines, Arcs, Turns, Loops, Shapes-style presets, Custom Path) | Most of Basic (shapes), Lines & Curves and Special presets | +| Motion paths | 64 presets + custom | 40 + Custom Path (Lines, Arcs, Turns, Loops, Basic shapes such as stars, heart, moon, teardrop; Lines & Curves such as zigzag, waves, S curves, spirals, bounces; Special such as figure 8, bean, peanut, neutron) | Remaining ~24 presets (some Basic shapes, Lines & Curves variants) | | Media (Play, Pause, Stop) | 3 (+ Seek) | 3 | Seek, bookmarks as triggers | By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly @@ -65,14 +65,15 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 |---|---|---| | Start On Click / With Previous / After Previous, duration, delay | yes | yes | | Effect Options (direction, sequence by paragraph) | yes | yes | -| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) | +| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) | | By-paragraph text builds, sub-bullets follow parents | yes | yes | -| Triggers (on click of a shape) | yes | yes | +| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step | | Triggers on a media bookmark | yes | no | | Animation Painter | yes | yes (#54) | | Animation Pane: list, reorder, remove | yes | yes | -| Animation Pane: advanced timeline (bars you drag) | yes | **no** | -| Preview in the editor (current effect, from selected) | yes | partly | +| Animation Pane: advanced timeline (bars you drag) | yes | yes: bars from the show schedule, drag the body for delay, the right edge for duration, seconds ruler (`animpane_tests`) | +| Preview on the editor canvas (the current slide) | yes | yes | +| Preview from the selected effect | yes | no | | Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) | | Multiple animations on one object | yes | yes | | Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) | @@ -81,17 +82,23 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Work | Hours | |---|---| -| Animation Pane timeline, preview from selected, effect dialog depth | 5–8 | +| Preview from selected, effect dialog depth | 4–6 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | | Rewind and other effect options on round trip (#77) | 1–2 | -| Transition sounds | 1–2 | +| Transition sounds: the rest of PowerPoint's built-in list (Applause, Arrow, Bomb…) | 1 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | | Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | -| **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | +| **Total** | **~15–27** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) | +| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) | +| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open | | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | +| 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked | +| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) | +| 2026-10-11 | minor | Morph animates a changed picture crop (#118) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/architecture.md b/docs/architecture.md index bd52ab44..31bac443 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,10 +1,10 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what -exists. 117k lines of Rust in 23 crates, 3 apps and `xtask`; 504 `#[test]` functions +exists. 202k lines of Rust in 24 crates, 3 apps and `xtask`; 1,641 `#[test]` functions (`cargo xtask stats` counts them per crate). ## Layers @@ -15,13 +15,13 @@ nothing below L6 uses egui, eframe, winit or rfd. | Layer | Crates | What they do | |---|---|---| -| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder | +| L0 | `geom`, `color`, `metafile`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder; EMF/WMF read into a neutral vector model (`metafile`) | | L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer | | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | App-owned export layout and rendering through the shared `craft-pdf` writer | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -55,7 +55,8 @@ effects project cards through a perspective camera and `anim::layer_mesh` subdiv textures stay straight; soft edges are per-corner opacities. The show renders the slides once per transition and only composites cached textures each frame; `render::composite_transition` is the CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape -comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and +comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor +canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. ## File I/O @@ -78,10 +79,24 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch | +| 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs | +| 2026-10-14 | trivial | Command registry: 228 (`comment.next`, `comment.prev`) | +| 2026-10-14 | trivial | Photo Album dialog and `insert.photoAlbum` (227 commands, 28 dialogs) | +| 2026-10-14 | trivial | Edit Photo Album (`insert.photoAlbumEdit`) reopens the Photo Album dialog and re-flows those slides (228 commands, 28 dialogs) | +| 2026-10-14 | minor | Create New Theme Colors (`design.colors` with no params); command count stays 226 | +| 2026-10-14 | minor | Create New Theme Fonts (`design.fonts` with no params); command count stays 226 | +| 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 | +| 2026-10-14 | minor | Command registry is 227 (`design.effects`). Dialogs stay 27. The test-count line stays 504 | +| 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 | +| 2026-10-13 | trivial | Animation preview draws the current slide's timeline on the editor canvas | +| 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | +| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text shaping for notes and footers; slide layout, rasterization, font selection, text mapping, links and outlines remain app-owned. PDF embedding and all Hayro production dependencies -are disabled. Generated-PDF assertions use a std-only parser as a development dependency. +are disabled. Native builds enable parallel compression on the same pinned Krilla backend; +WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07741fde..b7b6f013 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,11 @@ Start the app with `--control `. The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +The server bounds what a client can hold: 16 connections at once (more get `{"ok":false,"error":"busy: …"}` +and are closed), 16 MiB per request line (longer closes the connection), 64 queued requests (more are +answered `busy`), 10 minutes idle and 30 seconds per blocked reply write before the socket is closed, and +60 seconds waiting for the UI (`"error":"timeout"`). + ```json {"id": 1, "method": "engine.execute", "params": {"command": "shape.insert", "params": {"preset": "roundRect", "rect": [100, 100, 240, 120], "text": "Hello"}}} {"id": 1, "ok": true, "result": {"id": 258}} @@ -12,12 +17,17 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | Method | Params | What it does | |---|---|---| -| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) | +| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | -| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) | +| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | +| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show: digits then Enter go to that slide, G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc clears a slide number, the grid and zoom before it leaves the show) | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show; Escape stops the canvas animation preview) | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | @@ -26,8 +36,8 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.screenshot` | `{path?}` | PNG of the whole window (base64 when no path) | | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | -| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | +| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude -and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell. +and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..a5f72fe7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -16,7 +16,7 @@ PowerPoint opens and saves; Viewer = opens only). | Format | PowerPoint | DeckCraft read | DeckCraft write | Fidelity / notes | Tested by | |---|---|---|---|---|---| -| `.pptx` presentation | Editor | yes | yes | Broad (see below); never measured on real decks | `crates/pptx/tests/roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs` (30 tests); opened in PowerPoint by hand 2026-10-06 | +| `.pptx` presentation | Editor | yes | yes | Broad (see below); an owned corpus of decks we author round-trips tables, charts, animations, media, sections, masters and layouts, comments, links and actions, custom shows, rotated groups, inherited placeholders, text effects and body layout, theme effects, fills, bevels, connectors, 3-D models and animation bounce end. Not measured against PowerPoint renders or decks from outside this repo | `crates/pptx/tests/corpus.rs`, `corpus_kinds.rs`, `corpus_elements.rs`, `corpus_style.rs`, `corpus_themes.rs`, `corpus_charts.rs`, `corpus_media.rs`, `roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs`; opened in PowerPoint by hand 2026-10-06 | | `.potx` template | Editor | yes (as a presentation) | no: written with the `.pptx` content type | `file.saveTemplate` is Save As under another name | none | | `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Doesn't start in show mode | none | | `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none | @@ -25,10 +25,10 @@ PowerPoint opens and saves; Viewer = opens only). | `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | -| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | -| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests | +| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | +| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | -| Animated GIF of the deck | export | n/a | no | | none | +| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | | Print | yes | n/a | no | PR #66 (browser) open | none | @@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -47,8 +47,8 @@ PowerPoint opens and saves; Viewer = opens only). | WMV | yes (Windows) | embedded, not playable | | | Online video (YouTube embeds) | yes | no | Cloud feature | | Embedded OLE objects | yes | kept on round trip (opaque), not insertable or editable | `ShapeKind::Opaque` | -| 3-D models (`.glb`) | yes | no | | -| Embedded fonts | yes | no | `p:embeddedFontLst` ignored | +| Embedded fonts | yes | yes | `p:embeddedFontLst` read and written. TrueType, OpenType and Embedded OpenType faces are used to draw; MicroType Express subsets are kept and written back, not drawn | +| 3-D models (`.glb`) | yes | kept | The model part round-trips (`p:contentPart`, including an `mc:AlternateContent` choice and its fallback picture). Not drawn as a mesh and not inserted from the ribbon | ## PPTX coverage (PresentationML / DrawingML) @@ -61,45 +61,67 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | No notes-master editing view | -| Handout master | no | no | | +| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master. Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown | +| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master | | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | -| Effects: shadow, glow, soft edges, reflection | yes | yes | | -| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered | -| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields | +| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | +| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow | +| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | +| Tables with styles | yes | yes | Merges and both diagonal borders round-trip | +| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | +| SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | | Transitions incl. p14/p15 (Vortex, Morph…) | all 48 | all 48 | `every_transition_kind_round_trips` | -| Animations: timing tree, build paragraphs, triggers, motion paths | yes (flattened to the Animation Pane list) | yes (rebuilt) | `every_animation_effect_survives`; rewind lost (#77) | +| Animations: timing tree, build paragraphs, triggers, motion paths | yes (flattened to the Animation Pane list) | yes (rebuilt) | `every_animation_effect_survives`; repeat, triggers and bounce end kept; rewind lost (#77) | | Comments: legacy | yes | yes | | -| Comments: modern threaded (`p188`) | yes | no (written as legacy) | Replies and resolved state lost (#70) | +| Comments: modern threaded (`p188`) | yes | yes | Written next to the flattened legacy part; replies and resolved state round-trip (`threads_and_resolved_round_trip`) | | Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present | -| Tags, custom XML, VBA | no / no / no | | | -| Embedded fonts | no | no | | +| Embedded fonts | yes | yes | MicroType Express subsets kept, not drawn | +| Tags (`p:custDataLst`) on the presentation, slides and shapes | yes | yes | Add-ins such as think-cell keep their identity; `tags_survive_round_trip` | +| Custom XML data, sensitivity label (`docMetadata/LabelInfo.xml`), custom document properties | kept verbatim | yes | SharePoint metadata and the label survive a save; `custom_xml_label_and_custom_properties_survive_save` | +| VBA | no | no | | | Encryption (password to open) | no | no | | ### What would move the PPTX number -1. An owned corpus of real-world-shaped decks (corporate templates, SmartArt, chartex charts, EMF, - embedded fonts, every transition and animation) in a corpus repo, with import → render → export - → re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint). +1. A larger owned corpus (corporate templates, SmartArt, chartex charts, EMF, embedded fonts, + every transition and animation) in a corpus repo. `crates/pptx/tests/corpus.rs` and + `corpus_kinds.rs` already check open → save → reopen (parts and text) for decks we author: + tables, charts, animations, media, sections and masters/layouts among them. Still needed: + render checks and a PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). -3. SVG rendering, and clip regions inside EMF/WMF. +3. SVG rendering. EMF+ records inside EMF/WMF are still not drawn. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | +| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | +| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | +| 2026-10-13 | minor | Embedded fonts read and written; TrueType, OpenType and Embedded OpenType faces drawn | +| 2026-10-13 | minor | Owned corpus round-trip (parts and text). Notes-master header, footer, date and slide-number text is written back | +| 2026-10-13 | minor | Owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds (including custom-show return) survive a save | +| 2026-10-13 | minor | Owned corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save | +| 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) | +| 2026-10-14 | minor | Chart trendlines are drawn (linear, exponential, logarithmic, polynomial, power, moving average, forecast, equation and R²) | +| 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice | +| 2026-10-13 | minor | Add-in tags on the presentation, slides and shapes read and written | +| 2026-10-13 | minor | Custom XML parts, the sensitivity label and custom properties kept on save | +| 2026-10-13 | minor | Owned corpus round-trip for tables, charts, animations, media, sections and masters. Handout-master header, footer, date and slide-number text is written back | +| 2026-10-14 | minor | PNG and JPEG slide export record their resolution (`scale` × 72 dpi; the default 2× picture is 144 dpi) | +| 2026-10-13 | trivial | Unchanged charts keep the embedded workbook (`ppt/embeddings/`) and chart relationships (#335) | | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) | +| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited | +| 2026-10-11 | minor | Animated GIF of the deck: export yes (#171) | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | +| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..4adfda40 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -17,16 +17,16 @@ revision history. | # | Gap | Category | Beta | Hours | Doc | |---|---|---|:-:|---|---| -| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | +| 1 | PPTX corpus is only decks we author (no PowerPoint reference renders) | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | -| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | -| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) | +| 3 | PPTX round trip drops media options and rewind (#74, #77) | File format | B | 2–4 | [file-format-parity.md](file-format-parity.md) | +| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 4–8 | [file-format-parity.md](file-format-parity.md) | | 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | | 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) | -| 10 | Animation Pane: no timeline, thin preview; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) | +| 10 | Animation Pane: preview plays the whole slide, not from the selected effect (timeline done); 64 of ~190 effects | Feature | B (pane) | 8–14 | [animation-parity.md](animation-parity.md) | | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -48,41 +48,64 @@ revision history. - **Estimate:** 4–8 h (backend probing outside the process or a safe-mode fallback, a software renderer path). Needs a tester with the hardware. **Beta blocker.** -### No soak or long-session testing -- **Evidence:** unit tests (504), PPTX malformed/fuzz tests and the command panic guard exist; no - random command sequences over big decks, no memory or undo-depth soak. -- **Impact:** unknown crash and slowdown rate in long sessions. -- **Estimate:** 4–8 h. +### Soak testing has no memory or timing budgets +- **Evidence:** `crates/engine/src/soak_tests.rs` runs seeded random command sequences over a + ~100-slide deck (validity, selection, undo/redo exactness, bounded undo, save/reopen in both + formats every 100 steps); `DECKCRAFT_SOAK_STEPS` runs it long. It found a chart `clamp` panic + that blanked whole slides. `crates/ui-egui/src/ui_soak_tests.rs` drives the whole window with + seeded random clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the sample + deck, checking document validity, bounded undo/journal/thumbnail/texture caches, zero caught + command and render panics (`Session::panics_caught`, `deckcraft_render::panics_caught`) and a + mean frame under 250 ms; 4 × 3000-action runs are clean. +- **Impact:** long-session crash rate is sampled in CI; RSS is not measured directly. +- **Estimate:** 1–2 h (RSS sampling per platform). ## File formats Detail in [file-format-parity.md](file-format-parity.md). -### PPTX has never met a corpus of real decks -- **Evidence:** every PPTX test (`crates/pptx/tests`, 30 tests) uses decks DeckCraft generates; - the 2026-10-07 alpha checklist already listed this as open. No reference renders from PowerPoint - exist to compare against. +### PPTX corpus is only decks we author +- **Evidence:** every PPTX test (`crates/pptx/tests`) uses decks built in this repo; the + 2026-10-07 alpha checklist already listed this as open, and no reference renders from PowerPoint + exist to compare against. The owned corpus checks open → save → reopen (package parts and text): + `corpus.rs` (a talk with a table, chart, picture, link and comment; a notes master's header and + footer), `corpus_kinds.rs` (tables, charts, animations, media, sections, several masters and + layouts; a handout master's header, footer, date and slide number), `corpus_elements.rs` + (comments, hyperlinks and actions, custom shows, grouped rotated objects, placeholders that + inherit from the layout), `corpus_style.rs` (text effects, autofit, columns, vertical and RTL + text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels, + connectors), `corpus_themes.rs` (PowerPoint-shaped themes and two masters with their layouts, + effects, text and shape hyperlinks and actions), `corpus_charts.rs` (a combo chart with data + labels and trendlines, a merged table with diagonal borders, text columns, autofit, vertical + text, a WordArt warp) and `corpus_media.rs` (SmartArt and OLE kept as XML, with no diagram or + embedding parts, which stay with #203; audio and video bookmarks and trims; a section holding a + hidden slide; slide-number and date fields; ink as freeform lines; `.glb` 3-D models; animation + repeat, bounce end and triggers). Losses these checks found are fixed: notes- and handout-master + text, paragraph default run properties (`defRPr`, including a link), action sounds, a run's + inner shadow, reflection and soft edge, a bevel preset with no kept `sp3d`, and text Mouse Over + actions. Still open: media options (#74), animation rewind (#77) and a corpus of decks from + outside this repo. - **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks - (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is unknown. -- **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules - `standards/test-corpora.md`), add import → render → export → re-import checks, have the owner open - outputs in PowerPoint, fix what breaks. **Beta blocker.** + (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown. +- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules + `standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix + what breaks. **Beta blocker.** -### Round trip loses comment threads, media playback options and animation rewind (#70, #74, #77) -- **Evidence:** issues filed 2026-10-10 by an automated reviewer; the writer emits legacy comments - only (no `p188` modern comments with replies and resolved state, though the reader understands - them); media playback options and the animation rewind flag aren't written back. -- **Impact:** a deck opened and saved silently loses review state and show behaviour. -- **Estimate:** 3–6 h. **Beta blocker.** +### Round trip loses media playback options and animation rewind (#74, #77) +- **Evidence:** issues filed 2026-10-10 by an automated reviewer; media playback options and the + animation rewind flag aren't written back. Comment threads left with #70: the writer now emits + the `p188` modern part next to the legacy one. +- **Impact:** a deck opened and saved silently loses show behaviour. +- **Estimate:** 2–4 h. **Beta blocker.** ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn). + pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ only + pictures play their EMF+ records). Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. -- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An - EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip. -- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF/WMF clip regions 2–4 h. +- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. +- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates). ### Template, slide-show and macro variants are saved as plain `.pptx` - **Evidence:** `crates/pptx/src/write/mod.rs` always writes the @@ -115,15 +138,17 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** handouts and speaker notes are printed in many workplaces. - **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.** -### No video or animated GIF export -- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF. +### No video export; the animated GIF has no transitions or builds +- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes + an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF + doesn't animate transitions or build animations. - **Impact:** sharing a talk as a video is a common request. -- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part, - FilmCraft has one to copy). +- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for + transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy). -### Embedded fonts aren't read or written -- **Evidence:** no `p:embeddedFontLst` handling in `crates/pptx`. -- **Impact:** decks that rely on embedded fonts reflow. +### Embedded MicroType Express font subsets aren't drawn +- **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in. +- **Impact:** decks that embed a MicroType subset reflow until that face is installed. - **Estimate:** 3–5 h. ## Features @@ -138,57 +163,119 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.** -### Tab inside a text box ends text entry (#78) -- **Evidence:** #78. -- **Impact:** tab stops and indented lists can't be typed. -- **Estimate:** 1–2 h. - ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. + chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, + box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else + written from the model, with a plain chart fallback). Waterfalls float and total, funnels, + treemaps (squarified, nested by level with group banners) and sunbursts (one ring per level) + have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule), + and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept + (their cached geography too, also through edits) and drawn as value-coloured tiles with a + colour scale, since DeckCraft ships no map boundaries. Radar charts are drawn polar (filled + or lines), stock charts as high-low lines with close dashes or up/down bars and saved as + `c:stockChart`, and combo charts start as columns with a line. Trendlines round-trip + (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and + moving average, with forecast and the equation and R² label. A moving average is drawn with + neither label. A combo chart's secondary value axis is read (`y2_*`, + `Series.secondary_axis`) and is not written back when the chart is rebuilt. + An unchanged chart's embedded workbook now survives save (#335); editing the chart still + drops it rather than writing a stale spreadsheet. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). -### SmartArt is pre-drawn shapes only -- **Evidence:** `crates/pptx/src/read/shapes.rs` reads SmartArt's drawing part as a group; no text - pane, no layout switching, the diagram data isn't regenerated. -- **Impact:** editing a SmartArt diagram from PowerPoint breaks it. -- **Estimate:** 10–16 h. +### SmartArt editing is shallow +- **Evidence:** a SmartArt graphic keeps its outline (`Shape.diagram`) and has a Text Pane, Add + Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On + `.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the + cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the + data model, so they get the Text Pane too. 15 layouts (bullet and block lists, five process, two cycle and + a radial, hierarchy, two pyramids, two matrices), four SmartArt Styles and Change Colors + (colourful or one accent). Still missing: DeckCraft's own layout definitions are simple (not yet + checked in PowerPoint itself); PowerPoint has ~200 layouts, picture layouts and 3-D styles; editing an + imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing + isn't drawn. +- **Impact:** a PowerPoint diagram edited in DeckCraft changes look. +- **Estimate:** 6–10 h. ### Animation Pane, effect count and preview -Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. +Animations ▸ Preview and the pane's Play All play the current slide's animations on the editor +canvas (Escape or a click stops them), and the pane has an advanced timeline with draggable bars +(`crates/ui-egui/src/animtimeline.rs`). Still open: previewing from the selected effect, and most +of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8–14 h; the pane is a +**beta blocker**. + +### Interactive decks: media-action links, audio-triggered effects and click boxes +- **Evidence:** the slide show plays click-triggered animations, keeps each slide's state across + visits and ignores shapes an animation has hidden (`crates/anim/src/interact.rs`). Still open, + found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score + counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486 + shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are + not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do + not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the + first effect of a trigger into "after previous", so a trigger with several click steps saved from + DeckCraft plays all of them at once. +- **Impact:** games built around sound effects on buttons, or around a button that counts up with + repeated clicks, lose that part when they are saved from DeckCraft or played in it. +- **Estimate:** 4–8 h. ### Notes and Handout masters -- **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it, - and no handout master at all. -- **Estimate:** 4–6 h. +- **Evidence:** View ▸ Handout Master and View ▸ Notes Master edit both masters (placeholders, + background, a slides-per-page preview for handouts) and PPTX keeps them. Notes pages (view and + PDF) place the slide and notes where the notes master does and print its header, date, footer + and page number in their own fonts, sizes and colours; PDF handouts print the handout master's. + Notes Master view previews the slide in its slide-image placeholder. PDF pages lay master text + out with the text engine (wrapping, mixed formatting), and the Notes Page view draws the same + layout. Left: the view draws it in the interface font (sizes, colours and weights kept). +- **Estimate:** done for beta. ### Edit points, text warp, 3-D -- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 - `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or - command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by - 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting - render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, - only the three-point rig, and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, - rigs and materials, Format Shape page). +- **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a + freeform, vertices drag, Ctrl/Cmd-click adds or deletes, smooth/straight/corner points and Bézier + handles; no Straight/Curved Segment or Open/Close Path), "Text warp transforms" (partial: all 40 + `prstTxWarp` presets render, their adjusts round-trip, Shape Format ▸ Text Effects ▸ Transform + applies one with `format.textWarp`, and a selected warp shows a yellow handle per adjust that + dragging sets; arc/circle placement can differ from PowerPoint by 1–3 % of the path length), + "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting render + as approximations and `scene3d`/`sp3d` round-trip as raw XML; extrusions draw their sides under + turned cameras, each lit for its own normal; the preset light rigs, rig directions and materials + shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); contours + draw as a band round the face outline (not round the extruded silhouette); Format Shape ▸ + Effects has 3-D Format and 3-D Rotation). +- **Estimate:** 1 h (edit points: segment types, open/close path), 1–2 h (fitting the other rigs + and materials to PowerPoint renders). ### Proofing: spelling, thesaurus, AutoCorrect -- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings - (`crates/engine/src/cmd/review.rs`); no dictionaries, no thesaurus, no AutoCorrect. -- **Estimate:** 8–14 h (Hunspell-format dictionaries under an open licence per language). +- **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has + (`crates/engine/src/spell.rs`: affixes, cross products, one level of continuation, compounds, + case rules, suggestions), each run in its own language (`lang` from PPTX), with a user dictionary + for Add to Dictionary. Red squiggles on the current slide come from a worker thread, with a + context menu to correct, ignore or add; table cells and speaker notes are squiggled too, each + with a right-click menu; repeated words (`the the`) are flagged with Delete Repeated Word; + Review › Language marks the selection's proofing language or Do Not Check Spelling + (`lang`/`noProof` round-trip in PPTX). No grammar checking. The web build checks on the UI + thread a few milliseconds per frame. No dictionaries ship with DeckCraft, so without one + installed (always on Windows and the web) only a short list of common misspellings is found. + `review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has + PowerPoint's options (two initial capitals, sentence and day capitals, caps lock, fractions, an + editable replace list); no AutoFormat of ordinals or automatic bulleted lists, no undo of a + single correction. +- **Estimate:** 1–2 h (bundle openly licensed dictionaries); 2–4 h (AutoFormat as you type). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. - **Estimate:** 3–6 h to review, land and round-trip. ### Smaller missing rows -Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition -sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical -matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide -show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile -encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). +Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h; +`insert.zoom` makes linked pictures with Return to Zoom), reuse slides pane with thumbnails +(1–2 h; `slide.reuse` inserts from a file), laser pointer from the presenter window (1–2 h), +artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove +background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record +audio/screen and record slide show (8–12 h), compare presentations beyond slide-level accept +(reject, per-object accept, markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 +agile encryption), ink to text and math beyond Ink to Shape (6–10 h), 3-D models and stock content +(6–10 h; Insert › Icons places a Lucide stroke set). ## UI/UX @@ -203,9 +290,13 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. -- **Command palette defaults to an Export entry that fails** (#79). 0.5 h. +- **Dialogs**: 41 dialogs vs PowerPoint's ~60. In since the d556ff9 count of 27: Font, Paragraph + and Tabs, Bullets and Numbering, Find and Replace, Replace Fonts, Action Settings, Size and + Position, Date and Time, Photo Album (Edit Photo Album reopens it), Create New Theme Colors and + Create New Theme Fonts (each edits the open master's scheme, not a saved library; theme fonts + are latin only), Grid Options, AutoCorrect and Paste Special. Format Background picture options + are pane controls on `design.background`, not another dialog. Still missing: Print, Insert + Object…. 4–8 h. ## Hardware @@ -217,7 +308,11 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **No pen tilt and no pinch-zoom** (it does not keep the point under the pointer). Pen pressure + scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen + width; a stroke saved as a flat `0.5` still draws at the pen width). A two-finger twist rotates + the selected shapes, and a trackpad swipe turns the page when the slide fits the window and + during the show. 2–4 h. ## Localization @@ -230,20 +325,24 @@ Detail in [localization-parity.md](localization-parity.md). (Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK. Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h. -- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h. - **UI isn't mirrored for right-to-left languages.** 6–10 h. - **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h. ## Performance -- **No baseline.** Nothing measures open time, render time per slide, show frame rate or memory - against PowerPoint. 4–8 h for a benchmark suite in `xtask`. +- **Baseline measured, not compared.** `cargo xtask bench` (and `deckcraft-cli bench FILE`) times + open, per-slide render, save, PPTX round trip and PDF export, and peak memory, against a saved + baseline; show frame rate isn't measured and there are no PowerPoint numbers to set beside it. + 2–4 h. - **Morph aligns outlines every frame** (#67, PR open). 1 h. ## Ecosystem -- **8 original themes, no template gallery.** PowerPoint offers dozens of designs online and - offline. 8–14 h for a larger original set (asset policy: our own designs only). +- **Six original starter decks; PowerPoint's large template gallery remains.** File › New and the + start screen offer Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk + (`file.new {template}`), each a few slides on one of our themes. Dozens of further offline + designs, and any online gallery, are still absent. 4–8 h for a larger original set (asset policy: + our own designs only). - **No add-ins or VBA.** Out of scope by policy; MCP/CLI automation is the alternative. ## AI features @@ -256,9 +355,66 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | +| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain | +| 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn | +| 2026-10-13 | minor | WordArt Transform gallery: Shape Format ▸ Text Effects lists the 40 presets and No Transform; `format.textWarp` applies one (adjust handles still open) | +| 2026-10-13 | minor | Edit Points: smooth/straight/corner point types, Bézier handle dragging, right-click point menu, no selection box while editing | +| 2026-10-13 | minor | Edit Points: convert to freeform, drag, add and delete vertices (`shape.editPoints`, `shape.movePoint`, `shape.addPoint`, `shape.deletePoint`); left: point types and control handles | +| 2026-10-13 | minor | Owned PPTX corpus round-trip (parts and text). Notes-master header and footer text is kept; the corpus gap is now a larger set and PowerPoint reference renders | +| 2026-10-13 | minor | Owned PPTX corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds kept. Embedded fonts and #70/#74/#77 stay open | +| 2026-10-13 | minor | Owned PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, are kept. #70, #74, #77 and embedded fonts stay open | +| 2026-10-13 | minor | Benchmark suite (`cargo xtask bench`, `deckcraft-cli bench`): the performance gap is now frame rate and a PowerPoint comparison | +| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open | +| 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip | +| 2026-10-14 | trivial | A moving average trendline is drawn without an equation or an R² label | +| 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | +| 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | +| 2026-10-14 | trivial | 3-D contours | +| 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side | +| 2026-10-14 | trivial | 3-D extrusions draw their sides under turned cameras | +| 2026-10-14 | minor | Insert › Icons places Lucide 1.56.0 stroke icons as custom shapes. The checklist row stays missing until 3-D models and stock content exist | +| 2026-10-14 | minor | Size and Position dialog landed (position, size, rotation, scale, lock aspect, one undo). The dialog gap remains Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object and the rest of the ~60 | +| 2026-10-14 | minor | File › New has six original starter decks; a large template gallery is still open | +| 2026-10-14 | minor | An IME composition in a table cell is underlined in place, and the caret and candidate window sit in that cell | +| 2026-10-14 | trivial | Photo Album dialog counted (28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain | +| 2026-10-14 | trivial | Edit Photo Album reuses the Photo Album dialog (still 28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain | +| 2026-10-14 | minor | Create New Theme Colors is in (dialogs 27 → 28). Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object stay open | +| 2026-10-14 | minor | Create New Theme Fonts is in (dialogs 27 → 28): heading and body latin on the open master. Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object stay open | +| 2026-10-14 | minor | Insert › Date and Time dialog. Dialog count 27 → 28; Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object are still missing | +| 2026-10-13 | minor | Artistic effects: 7 of 22 (blur, pencil grayscale, line drawing, film grain, cutout, glow edges, photocopy) applied, removed and round-tripped (`a14:imgLayer`) | +| 2026-10-14 | trivial | Notes Page view draws master text wrapped, as the PDF does | +| 2026-10-14 | trivial | PDF master header/footer text wraps and keeps mixed formatting | +| 2026-10-14 | trivial | Notes Master slide preview; master header/footer text keeps its formatting | +| 2026-10-14 | minor | Notes pages and PDF handouts follow the notes and handout masters | +| 2026-10-14 | minor | Notes Master view: the masters gap is now notes pages and PDF handouts following the masters | +| 2026-10-14 | minor | Handout Master view: the masters gap is now the Notes Master view and handout text in PDF | +| 2026-10-13 | minor | Owned PPTX corpus for tables, charts, animations, media, sections and masters. Handout-master text is kept; the corpus gap is now a larger set and PowerPoint reference renders | +| 2026-10-13 | minor | Laser pointer in the slide show (checklist row Partial). Highlighter, eraser and a laser driven from presenter view remain | +| 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) | +| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 | +| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 | +| 2026-10-14 | minor | Format Background picture and solid options are in the existing pane. Dialogs stay 27 | +| 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | +| 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words | +| 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web | +| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | +| 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | +| 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | +| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open | +| 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains | +| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | +| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | +| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) | +| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | +| 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | +| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | +| 2026-10-11 | minor | Removed "Tab inside a text box ends text entry" (#78, fixed) | +| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..3a334d4f 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -13,7 +13,8 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | +| Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same | +| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | | Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a | | Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no | @@ -43,8 +44,9 @@ because it mostly follows from hardware use. |---|---|---| | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | -| Pinch to zoom, two-finger gestures | yes | no | -| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | +| Pinch to zoom, two-finger gestures | yes | partial: pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes | +| Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans | +| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | @@ -63,13 +65,19 @@ outlines every frame (#67, fix open), and the decoders run in software. | GPU slide rasterization (vello's GPU/hybrid path) | 12–20 | | Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 | | Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) | -| Pen pressure, pinch zoom | 3–6 | +| Pen tilt, pinch zoom, trackpad gestures | 2–5 | | Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) | -| **Hardware total** | **~26–45** | +| **Hardware total** | **~25–44** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size | +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | +| 2026-10-14 | trivial | Community PR wave folded: one row each for pinch/two-finger gestures and pen pressure | +| 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | +| 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged | +| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3437cf53..f1cf8047 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft's interface is English only today: strings are literals in the UI and engine code, with no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 @@ -44,8 +44,9 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese. Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows the system (#15), plural and number formatting, and an `xtask` check that every key has a -translation. IME composition landed with #101 (slide text, notes and text fields; one undo step -per composition); the hours that included it are kept until the next re-measure. +translation. IME composition landed with #101 (slide text, table cells, notes and text fields; +one undo step per composition, underlined in place); the hours that included it are kept until +the next re-measure. ## Proofing languages @@ -57,5 +58,6 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret | | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) | | 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle | diff --git a/docs/mcp.md b/docs/mcp.md index f7e24c7e..b2f879b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -13,6 +13,58 @@ deck. } ``` +## Confining file access + +```sh +deckcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"deckcraft": {"command": "deckcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_presentation` and + `file.open`, and `insert_picture`, `insert.picture`, `picture.change`, `insert.audio` and + `insert.video` with a `path`. Pictures, audio and video are copied into the deck when they are + inserted, so playing them later reads nothing from disk. +- Every file a command writes must lie inside the **write root**: `save_presentation`, `export` + (every format, and every slide of an image export), `file.save`, `file.saveAs`, `file.export`, + and `render_slide` with a `path`, plus the `.saving` temporary file a save goes through. +- Read and write access are separate. Give the same folder twice for both; a root left out grants + none of its access (with only `--automation-read-root`, nothing can be written to disk). Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.pptx`. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. DeckCraft's desktop app has no equivalent flags +yet, so a running app (`deckcraft --control`) is not confined. `--sample` is the operator's own +choice and is opened before the roots apply. + +What the roots do not cover: commands that take no path still read and change the open deck, and +`inspect_document` and `inspect_slide` return it. The system font scan and the app's own recovery +folder are fixed paths, not agent input, and are not checked. Anything that reads or writes a path +goes through one place in the engine (`io::read_file` and `io::write_file`); a test keeps the rest of the +engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Tools | Tool | What it does | @@ -20,7 +72,7 @@ deck. | `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered | | `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command | | `batch` | Run several commands in order and stop at the first error | -| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline | +| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) | | `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) | | `render_slide` | A slide as an image the model can look at | | `add_slide`, `go_to_slide` | Slides by layout | @@ -30,8 +82,11 @@ deck. | `screenshot` | The whole window, UI included (connected mode) | | `call_app` | Any [control protocol](control-protocol.md) method (connected mode) | +A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, series, title, type)`). Keys inside `run_command`'s `params` are that command's parameters. + Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +(GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..9843d75e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,38 +1,38 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-14 (summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 84%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P1 | 65 | 45 | 16 | 4 | 82% | +| P2 | 54 | 17 | 25 | 12 | 55% | | Area | Parity | |---|---| -| Application shell | 77% | -| Files | 71% | -| Slides | 90% | -| Views | 90% | -| Masters & themes | 85% | -| Shapes | 88% | +| Application shell | 81% | +| Files | 75% | +| Slides | 92% | +| Views | 98% | +| Masters & themes | 92% | +| Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | -| Tables | 89% | +| Text | 83% | +| Tables | 97% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 48% | +| SmartArt | 50% | +| Pictures | 60% | | Media | 67% | -| Transitions | 92% | -| Animations | 79% | -| Slide show | 66% | -| Review | 60% | -| Draw | 33% | -| Undo & clipboard | 88% | +| Transitions | 100% | +| Animations | 85% | +| Slide show | 68% | +| Review | 70% | +| Draw | 50% | +| Undo & clipboard | 100% | | Automation (✱) | 100% | | Platforms (✱) | 67% | @@ -45,7 +45,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Application shell | Start screen (new, templates/themes, recent) | P0 | D | | Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D | | Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P | -| Application shell | Quick Access toolbar / title bar commands | P1 | P | +| Application shell | Quick Access toolbar / title bar commands | P1 | D | | Application shell | Native menu bar (macOS) with every command | P1 | M | | Application shell | Command palette / Tell me search | P1 | D | | Application shell | Keyboard shortcuts for every common command | P0 | D | @@ -65,10 +65,10 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | | Files | Export slides as PNG/JPEG | P0 | D | | Files | Export outline / RTF | P2 | P | -| Files | Export video (MP4) / animated GIF | P2 | M | +| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file | P1 | M | +| Files | Reuse slides from another file | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | @@ -79,22 +79,22 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slides | Reset slide | P1 | D | | Slides | Slides from outline | P2 | D | | Slides | Copy/paste slides between decks | P1 | P | -| Slides | Zoom (summary, section and slide zoom) | P2 | M | +| Slides | Zoom (summary, section and slide zoom) | P2 | P | | Views | Normal view (thumbnails, slide, notes) | P0 | D | | Views | Outline view | P1 | D | | Views | Slide Sorter | P0 | D | | Views | Notes Page | P1 | D | | Views | Reading View | P1 | D | | Views | Zoom, fit to window | P0 | D | -| Views | Rulers, gridlines, guides | P1 | P | +| Views | Rulers, gridlines, guides | P1 | D | | Views | Smart guides | P1 | D | | Views | Grayscale / black and white view | P2 | D | -| Views | New window / arrange windows | P2 | M | +| Views | New window / arrange windows | P2 | P | | Masters & themes | Slide Master view (edit master and layouts) | P0 | D | | Masters & themes | Insert/rename/delete layouts, insert placeholders | P1 | D | | Masters & themes | Handout and Notes masters | P2 | M | | Masters & themes | Built-in themes (original designs) | P0 | D | -| Masters & themes | Theme variants | P1 | P | +| Masters & themes | Theme variants | P1 | D | | Masters & themes | Colour schemes (built-in and custom) | P0 | D | | Masters & themes | Font schemes (built-in and custom) | P0 | D | | Masters & themes | Effect schemes / background styles | P2 | P | @@ -102,7 +102,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Masters & themes | Hide background graphics | P1 | D | | Masters & themes | Slide size (16:9, 4:3, presets, custom) | P0 | D | | Masters & themes | Header & footer (date, slide number, footer) | P1 | D | -| Masters & themes | Browse themes from .pptx/.thmx | P2 | M | +| Masters & themes | Browse themes from .pptx/.thmx | P2 | D | | Masters & themes | Designer layout suggestions (offline) | P2 | M | | Shapes | Preset shapes (lines, rectangles, basic, arrows, equation, flowchart, stars, callouts) | P0 | D | | Shapes | Action buttons | P2 | D | @@ -110,7 +110,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Shapes | Connectors (straight, elbow, curved) | P0 | D | | Shapes | Connector glue to connection sites | P1 | D | | Shapes | Freeform, curve and scribble tools | P1 | D | -| Shapes | Edit points | P1 | M | +| Shapes | Edit points | P1 | P | | Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | D | | Shapes | Change shape | P1 | D | | Shapes | Text boxes | P0 | D | @@ -148,15 +148,15 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D | | Text | Text direction (rotated, stacked) | P1 | D | | Text | Find and replace, replace fonts | P1 | P | | Text | Spelling check | P1 | P | -| Text | Thesaurus | P2 | M | -| Text | AutoCorrect and smart quotes | P2 | M | +| Text | AutoCorrect and smart quotes | P2 | P | +| Text | Thesaurus | P2 | P | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | | Text | Hyperlinks (URL, slide, email) | P1 | D | @@ -164,12 +164,12 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Right-to-left and complex scripts | P1 | P | | Text | East Asian text (CJK fonts, line breaking) | P1 | P | | Tables | Insert table (grid picker, dialog) | P0 | D | -| Tables | Draw table | P2 | M | +| Tables | Draw table | P2 | P | | Tables | Table styles and style options | P0 | D | | Tables | Insert/delete rows and columns | P0 | D | | Tables | Merge and split cells | P1 | D | | Tables | Distribute rows/columns, sizes | P1 | D | -| Tables | Cell fill, borders, margins, alignment | P1 | P | +| Tables | Cell fill, borders, margins, alignment | P1 | D | | Tables | Keyboard navigation between cells | P0 | D | | Charts | Insert chart (column, bar, line, area, pie, doughnut, scatter) | P0 | D | | Charts | Other chart types (radar, stock, combo, waterfall, funnel, treemap, sunburst, histogram, box) | P2 | P | @@ -177,16 +177,16 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Charts | Chart elements (title, legend, labels, axes, gridlines) | P1 | P | | Charts | Chart styles and colours | P2 | P | | SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P | -| SmartArt | Text pane and layout switching | P2 | M | +| SmartArt | Text pane and layout switching | P2 | P | | Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D | | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | -| Pictures | Corrections, colour, transparency | P1 | P | -| Pictures | Artistic effects | P2 | M | +| Pictures | Corrections, colour, transparency (recolor and Set Transparent Color done; sharpen/soften and colour temperature pending) | P1 | P | +| Pictures | Artistic effects | P2 | P | | Pictures | Remove background | P2 | M | -| Pictures | Picture styles and borders | P1 | P | +| Pictures | Picture styles and borders | P1 | D | | Pictures | Change / reset picture | P1 | D | -| Pictures | Compress pictures | P2 | M | +| Pictures | Compress pictures | P2 | D | | Pictures | Paste image from clipboard (fails on Windows and Linux, #53) | P0 | P | | Pictures | Insert screenshot | P2 | M | | Media | Insert audio (any common format) | P0 | D | @@ -199,7 +199,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Transitions | Transition gallery (fade, push, wipe, split, cover, uncover, zoom, morph…) | P0 | D | | Transitions | Effect options and duration | P0 | D | | Transitions | Advance on click / after time, apply to all | P0 | D | -| Transitions | Transition sounds | P2 | M | +| Transitions | Transition sounds | P2 | D | | Transitions | Morph | P1 | D | | Animations | Entrance, emphasis, exit effects | P0 | D | | Animations | Motion paths | P1 | P | @@ -208,12 +208,12 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Animations | By-paragraph text builds | P1 | D | | Animations | Triggers | P2 | D | | Animations | Animation Painter | P2 | D | -| Animations | Preview in editor | P1 | P | +| Animations | Preview in editor | P1 | D | | Slide show | Full-screen show from start / current | P0 | D | | Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D | | Slide show | Presenter view (notes, next slide, timer) | P0 | P | | Slide show | Pen and highlighter ink during the show | P1 | P | -| Slide show | Laser pointer | P2 | M | +| Slide show | Laser pointer | P2 | P | | Slide show | Rehearse timings | P2 | D | | Slide show | Custom shows | P2 | D | | Slide show | Set up show (loop, kiosk, without animation) | P1 | D | @@ -221,13 +221,13 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slide show | Multi-monitor presenting | P1 | M | | Review | Comments (add, reply, resolve, delete) | P1 | D | | Review | Accessibility checker | P2 | D | -| Review | Compare presentations | P2 | M | +| Review | Compare presentations | P2 | P | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | -| Draw | Ink to shape / text | P2 | M | +| Draw | Ink to shape / text | P2 | P | | Undo & clipboard | Unlimited undo/redo | P0 | D | | Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D | -| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P | +| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D | | Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D | | Automation (✱) | Headless rendering and conversion | P0 | D | | Platforms (✱) | macOS, Windows, Linux builds | P0 | P | diff --git a/docs/roadmap.md b/docs/roadmap.md index c1bc627e..1a6c3910 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -17,17 +17,22 @@ points and lead the beta plan. |---|---|---|---| | Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 | | Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | -| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 | +| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window, See All Slides and zoom. The presenter timer pauses and resets, and notes have a size control. A custom show plays only its slides. Typing a slide number during the show shows it, and Enter goes there. Presenter view isn't placed on the second display automatically (drag it there; #433) | 2–4 | | Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 | | Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 | -| Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft`; threads are flattened on `.pptx` save (#70); spelling is a small built-in list | 4–8 | +| Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft` and `.pptx` (#70); Previous and Next walk every slide and the pane lists them; spelling uses installed Hunspell dictionaries (only a short built-in list where none is installed) | 4–8 | ## Current focus (2026-10-10) 1. **Stop losing work and crashing:** land Close's save prompt (#60, PR #75), the Windows GPU startup crashes (#40, #59), image paste on Windows/Linux (#53). 6–12 h. -2. **PPTX on real decks:** build an owned corpus, round-trip checks in CI, fix what breaks; - comment threads, media options and rewind on save (#70, #74, #77); EMF/WMF and SVG. 31–55 h. +2. **PPTX on real decks:** the owned corpus (`crates/pptx/tests/corpus*.rs`) round-trips decks we + author: tables, charts, animations, media, sections, masters and layouts, comments, links and + actions, custom shows, rotated groups, inherited placeholders, text effects and body layout, + theme effects, fills, bevels, connectors, 3-D models and animation bounce end, and keeps notes- + and handout-master text, paragraph defaults and action sounds. Still open: decks from outside + this repo, media options and rewind on save (#74, #77), relationship-bearing SmartArt and OLE + (#203), EMF+ records and SVG. 31–55 h. 3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h. @@ -40,14 +45,14 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` |---|---|---|---| | 1 | Stability: #60, #40/#59, soak and fuzz the editor | 10–20 | Stability 60 → 85% | | 2 | PPTX real-deck corpus and fixes | 20–35 | File formats 58 → 75% | -| 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, embedded fonts | 8–15 | File formats → 80% | +| 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, MicroType embedded-font subsets | 8–15 | File formats → 80% | | 4 | EMF/WMF and SVG pictures | 8–14 | File formats, Pictures | | 5 | Vector PDF, printing | 10–16 | File formats, Files | | 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% | | 7 | Font weights (#69), image paste (#53), Tab in text (#78) | 6–11 | Features | | 8 | Charts: data grid, elements | 6–10 | Features | -| 9 | Animation Pane timeline, preview, effect options | 5–8 | Features | -| 10 | Presenter view on the right display, timer controls | 3–5 | Hardware, Slide show | +| 10 | Presenter view on the right display, swap displays | 2–4 | Hardware, Slide show | +| 9 | Animation Pane: preview from the selected effect, effect options (the timeline is in) | 4–7 | Features | | 11 | Native macOS menu bar, Format Shape pane depth, start screen and window chrome fixes | 13–22 | UI/UX 60 → 75% | | 12 | Performance baseline and the obvious fixes | 4–8 | Performance | | **Total** | | **~120–210** | Ready ~55% → ~75% | @@ -58,11 +63,12 @@ reference renders, a Windows tester with the affected GPUs, native speakers to r ## After beta: toward full parity (~245–440 h more) -SmartArt editing, chartex chart types, the remaining animation effects and motion paths, Notes and -Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, recording, video and -GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video -decode, the other 21 languages with RTL mirroring, a larger original template gallery, and local AI -features (owner decision on models). Itemized with hours in [gaps.md](gaps.md). +SmartArt depth, the remaining animation effects and motion paths, edit-point segment types and +3-D depth, bundled proofing dictionaries, Zoom depth, recording, video export, legacy `.ppt` import +and `.odp` (owner decision), GPU rasterization, hardware video decode, pen tilt (pressure already +scales ink width) and pinch zoom, the other 21 languages with RTL mirroring, a larger template +gallery (six original starters ship), and local AI features (owner decision on models). Itemized +with hours in [gaps.md](gaps.md). ## Milestones @@ -79,8 +85,8 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M6 | Transitions and animations | mostly | Animation Pane timeline, remaining effects, transition sounds | | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | -| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | -| M10 | Views | done | grid options, New Window | +| M9 | SmartArt, equations, WordArt | partly | SmartArt layouts and PowerPoint-diagram fidelity, equations (PR #55); the text pane, warp gallery and warp handles are in | +| M10 | Views | done | Arrange All (grid options and New Window are in) | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | | M13 | Automation and CLI parity | done | | @@ -96,7 +102,7 @@ on common paths. On 2026-10-07 it was ~80% there with these blockers: | Blocker (2026-10-07) | Status 2026-10-10 | |---|---| | First real release run: signing, notarization, installers verified on each OS | Signed releases v0.1.0–v0.4.0 published; installers not hand-checked on each OS; Windows startup crashes on some GPUs (#40, #59) | -| PPTX fidelity on a corpus of real decks | Still open; now beta item 2 | +| PPTX fidelity on a corpus of real decks | Owned corpus checks in CI (tables, charts, animations, media, sections, masters, comments, links, custom shows, groups, inherited placeholders, text effects, body layout, theme effects, fills, bevels, connectors, 3-D models); notes- and handout-master text kept. Decks from outside this repo and #74/#77 stay open; beta item 2 | | Presenter view and Animation Pane polish | Still open; beta items 9 and 10 | | Soak and fuzz the editor | Still open; beta item 1 | | UI fidelity pass on the most-used ribbon groups and dialogs; first run | Partly (start-screen fixes #20, #49); beta item 11 | @@ -109,6 +115,27 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history | +| 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets | +| 2026-10-13 | minor | M9: the WordArt Transform gallery applies a text warp; adjust handles stay open | +| 2026-10-13 | trivial | Owned PPTX corpus check started; notes-master header and footer kept. Beta item 2 stays open for a larger corpus | +| 2026-10-13 | trivial | Owned PPTX corpus covers comments, links and actions, custom shows, rotated groups and inherited placeholders. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 | +| 2026-10-13 | trivial | Owned PPTX corpus covers text effects, body layout, theme effects, fills, bevels and connectors. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 | +| 2026-10-14 | trivial | Current focus: `corpus_media.rs` keeps 3-D models and animation bounce end; the real-deck corpus stays open | +| 2026-10-14 | trivial | Chart trendlines are drawn. The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus (item 2) stays open | +| 2026-10-14 | trivial | Chart trendlines round-trip in an owned corpus slice; the real-deck corpus (item 2) stays open | +| 2026-10-14 | trivial | Review workflow: previous and next comment across the deck | +| 2026-10-14 | minor | After beta: six original starter decks exist; a larger template gallery remains | +| 2026-10-14 | trivial | View: Zoom dialog (percent or Fit) | +| 2026-10-13 | trivial | Owned PPTX corpus covers tables, charts, animations, media, sections and masters; handout-master text kept. Beta item 2 stays open for a larger corpus | +| 2026-10-14 | trivial | Present workflow notes go to slide number entry | +| 2026-10-14 | trivial | Present workflow notes custom show playback | +| 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open | +| 2026-10-14 | trivial | Present workflow notes See All Slides and zoom | +| 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect | +| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | +| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | +| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..921732a5 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,11 +1,11 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in [animation-parity.md](animation-parity.md); display hardware in -[hardware-parity.md](hardware-parity.md). Area score: checklist breadth **66%** (measured), **~60% +[hardware-parity.md](hardware-parity.md). Area score: checklist breadth **68%** (measured), **~60% ready** (estimated), **10–16 h** to parity. ## Running the show @@ -14,20 +14,26 @@ ready** (estimated), **10–16 h** to parity. |---|---|---|---| | Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | | | Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck | +| Go to slide (number + Enter), first/last | yes | yes: the digits show as "Go to slide …" until Enter, Backspace, Esc, or 3 seconds | Slide numbers are 1-based; 0 and a number past the end do not move | +| Next/previous by trackpad swipe or mouse wheel | yes | yes | One flick turns one page. The pen ignores the flick | | Go to slide (number + Enter), first/last | yes | yes | | | Black / white screen (B / W) | yes | yes | | | End screen, Esc to leave | yes | yes | | | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | -| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | -| Animated GIFs play; pause from their corner button | yes | yes (#39) | | -| See All Slides grid during the show | yes | no | | -| Zoom into part of a slide during the show | yes | no | | +| A shape with a triggered animation plays it when clicked; shapes an animation has hidden cannot be clicked; a shape without an action on top of one with an action blocks it; the pointer is a hand over what can be clicked | yes | yes | Click boxes are bounding boxes; "play sound" actions (`ppaction://media`) do nothing yet | +| A slide the show returns to (link, arrow keys) looks as it was left; a triggered animation goes on where it stopped | yes | yes | A main-sequence step still playing when the show leaves is finished | +| "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | +| Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed, Custom Show) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`). A Custom Show action plays that show | +| Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable. Click sequence since #116. Slide Show ▸ Show Media Controls (on by default) hides the playback bar. Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media` | +| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls | +| See All Slides grid during the show | yes | yes: G, or All on the show bar and in presenter view; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | +| Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part | | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt | -| Laser pointer | yes | **no** | 1–2 h | +| Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot | | Subtitles and live captions | yes (cloud speech) | no | AI dimension | -| Custom shows | yes | yes | | +| Custom shows | yes | yes: Show plays that list in order and does not change Set Up; Set Up Slide Show can keep one for Play from Start | Hidden slides in the list are skipped | | Set Up Show: loop until Esc, kiosk (browsed at a kiosk), show without animation/narration, pen colour, slides range | yes | yes, most options | | | Use rehearsed timings | yes | yes | | @@ -37,13 +43,14 @@ ready** (estimated), **10–16 h** to parity. |---|---|---|---| | Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700) | | | Elapsed timer | yes | yes | | -| Pause / reset timer, clock | yes | no | | +| Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal | | Slide counter, previous/next/end buttons | yes | yes | | -| Notes font size, scrolling | yes | scrolling only | | -| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h | -| Swap displays | yes | no | | -| Pen, laser, black screen, zoom from presenter view | yes | keys only | | -| See all slides from presenter view | yes | no | | +| Keyboard navigation while Presenter View has focus | yes | yes | Shares the audience window's key handling; handled keys are consumed before buttons, and the presenter stays available at the end screen | +| Notes font size, scrolling | yes | yes: A- / A+ (and `[` / `]`), 12–48 pt in steps of 4, default 18; the notes scroll | | +| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h. Open PR #433 places the show on a second display when one is attached. This change does not | +| Swap displays | yes | no | Still open on #433 | +| Pen, laser, black screen, zoom from presenter view | yes | Black, White and Zoom buttons; the audience zoom is framed on the presenter slide | Pen and laser are driven from the audience window only; the presenter window does not move the laser dot | +| See all slides from presenter view | yes | yes: All slides opens the grid on the show | | | Rehearse with Coach (Speaker Coach) | yes (cloud) | no | AI dimension | ## Rehearse and record @@ -63,14 +70,24 @@ Reading View in a window with navigation: present (checklist row done). | Work | Hours | |---|---| -| Presenter view on the right display, swap displays, pause/reset timer, clock, notes size | 3–5 | -| Laser pointer, highlighter, eraser, keep-annotations prompt | 2–4 | -| See All Slides and zoom during the show | 2–3 | +| Highlighter, eraser, keep-annotations prompt, laser from presenter view | 2–4 | +| Presenter view on the right display and a swap-displays command | 2–4 | | Record slide show with narration (no camera) | 5–8 | -| **Total** | **~12–20** (the slide-show row of the feature table carries 10–16 h; recording overlaps the Media row) | +| **Total** | **~9–16** (the slide-show row of the feature table carries 10–16 h; recording overlaps the Media row) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% | +| 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click | +| 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests | +| 2026-10-14 | minor | Presenter View handles slideshow keys in its own viewport, including returning from the end screen and Escape; regression tests cover navigation and avoiding duplicate button activation | +| 2026-10-11 | minor | Media effects (Play, Pause, Stop) in the click sequence play with their step (#116) | +| 2026-10-13 | minor | The show has a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Presenter view still does not drive it | +| 2026-10-14 | trivial | Show Media Controls hides the show's playback bar and GIF button | +| 2026-10-14 | minor | Typing a slide number during the show shows it, and Enter goes there. Esc clears the number before it leaves the show. Checklist breadth stays 66% | +| 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% | +| 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% | +| 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..aa1893ac 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -11,8 +11,8 @@ number. | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | -| Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | +| **Feature breadth** (weighted checklist) | **84%** over 191 features (P0 92%, P1 82%, P2 55%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md); rows re-scored by the 2026-10-14 community PR wave | +| Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels of the d556ff9 snapshot (not re-measured since; the registry is 320 now) and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | | **Essentials user** | **~62%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -64,15 +64,15 @@ The weights are what those users actually depend on, written down so the total c | Dimension | Weight | Ready | Hours to full | Doc | Evidence | |---|---:|---:|---|---|---| -| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 79% measured. Depth lags: 37 of 191 rows are partial, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | +| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 84% measured. Depth lags: partial rows remain, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | | File formats | 20% | ~58% | 60–110 | [file-format-parity.md](file-format-parity.md) | PPTX reads and writes every main part and keeps unknown objects; files open in PowerPoint without repair. But it has never met a corpus of real decks, EMF/WMF/SVG pictures don't decode, comments/media/rewind settings are lost on round trip, and there is no `.ppt`, `.pptm`, `.odp`, video or GIF export | | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | -| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | +| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | | Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | -| Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) | +| Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes and 6 original starter decks; PowerPoint's large template gallery remains; no add-ins or VBA (out of scope by policy) | | AI features | 2% | ~10% | 20–40 (owner decision on models) | [gaps.md](gaps.md) | No Designer, Copilot, Speaker Coach, live subtitles or Translate (all Microsoft cloud services). Agents drive every command over MCP instead | | **Total** | 100% | **~55%** (Σ weight × ready = 55.3) | **~365–650** | | | | Automation (✱, not weighted) | | ahead | 0 | [mcp.md](mcp.md), [control-protocol.md](control-protocol.md) | Every command is scriptable from the CLI, a JSON control channel and MCP, headless or against the running app; PowerPoint for Mac has AppleScript and VBA only | @@ -150,7 +150,7 @@ mainstream number leaves out. | Discount | Factor | Evidence | |---|---:|---| | Launch and stability | ×0.90 | Some Windows PCs can't start the app (#40, #59), and the workaround is an environment variable; Close loses work (#60) | -| Discoverability and UI clarity | ×0.95 | Familiar ribbon and tabs; no native menu bar, start-screen glitches, a command palette whose default entry fails (#79) | +| Discoverability and UI clarity | ×0.95 | Familiar ribbon and tabs; no native menu bar, start-screen glitches | | Opening files people send them | ×0.90 | `.pptx` opens, but real decks are untested and EMF pictures and PowerPoint icons can come in blank (#56) | 80.1 × 0.90 × 0.95 × 0.90 = **61.6 → ~62%**. @@ -174,34 +174,34 @@ mainstream number leaves out. Breadth is measured from the checklist; "ready" adds depth and is estimated. Hours are to PowerPoint parity for that area. **Weight** is how much a PowerPoint user relies on the area -(points out of 110); the total "ready" is the weighted mean of the rows (6,865 / 110 = 62.4%), and -it is the Features row of the dimension table. Automation and Platforms are scored elsewhere. +(points out of 110); the total "ready" is the weighted mean of the rows (6,905 / 110 = 62.8%; the +Features row of the dimension table keeps ~62% until the next full re-measure). Automation and Platforms are scored elsewhere. | Area | Weight | Breadth (measured) | Ready (est.) | Hours | Main shortfalls | Doc | |---|---:|---:|---:|---|---|---| -| Application shell | 5 | 71% | ~55% | 8–14 | No native macOS menu bar, thin start screen (templates, recent), one window per presentation missing | [ui-parity.md](ui-parity.md) | -| Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) | -| Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | -| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | -| Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | -| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | -| Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | +| Application shell | 5 | 81% | ~55% | 8–14 | No native macOS menu bar, contextual tabs and multiple-window handling thinner than PowerPoint's | [ui-parity.md](ui-parity.md) | +| Files (commands) | 4 | 75% | ~50% | see file formats | Print, passwords, legacy and macro formats, video export; reuse slides is partial | [file-format-parity.md](file-format-parity.md) | +| Slides and sections | 6 | 92% | ~85% | 3–5 | Zoom (summary/section/slide) is partial, copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | +| Views | 4 | 98% | ~80% | 3–6 | Arrange All (Grid Options and New Window are in) | [ui-parity.md](ui-parity.md) | +| Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) | +| Shapes | 7 | 91% | ~75% | 8–14 | Edit points depth, WordArt depth (the Transform gallery and warp adjust handles are in) | [ui-parity.md](ui-parity.md) | +| Format (fill, line, effects) | 6 | 83% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | | Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | -| Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | -| Tables | 5 | 89% | ~75% | 3–6 | Draw table, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) | +| Text | 15 | 83% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), true vertical CJK, dictionaries, AutoFormat as you type, thesaurus depth, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | +| Tables | 5 | 97% | ~75% | 3–6 | Draw table depth, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) | | Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) | -| SmartArt | 4 | 33% | ~25% | 10–16 | Text pane, layout switching, the diagram data model on save | [parity-checklist.md](parity-checklist.md) | -| Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) | -| Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | -| Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | -| Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | -| Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | -| Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | -| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | -| Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | +| SmartArt | 4 | 50% | ~35% | 8–14 | PowerPoint diagrams editable (read their data model), the data model on save, more layouts | [parity-checklist.md](parity-checklist.md) | +| Pictures | 7 | 60% | ~45% | 12–20 | SVG, EMF+ records, clipboard paste on Windows/Linux (#53), remove background, artistic-effect and correction depth | [file-format-parity.md](file-format-parity.md) | +| Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, 3-D models and stock content, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | +| Transitions | 4 | 100% | ~80% | 4–8 | 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | +| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview from the selected effect, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | +| Slide show and presenter view | 8 | 68% | ~60% | 10–16 | Second-display placement, laser from presenter view, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | +| Review | 3 | 70% | ~45% | 8–14 | Compare depth, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | +| Draw (ink) | 2 | 50% | ~30% | 5–9 | Pen tilt, ink to math, lasso, ink to shape/text depth | [hardware-parity.md](hardware-parity.md) | +| Undo and clipboard | 3 | 100% | ~75% | 3–5 | Image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | -| **Total (feature areas)** | 110 | **79%** | **~62%** | **~140–240** | | | +| **Total (feature areas)** | 110 | **84%** | **~62%** | **~140–240** | | | ## What works today (evidence) @@ -210,8 +210,15 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Document model:** presentations, slides, masters and 11 standard layouts with placeholder inheritance (slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields, embedded media, unlimited undo on shared slide snapshots. -- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts, - backgrounds (solid, gradient, picture, pattern), slide size presets. +- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts + (Design › Colors › Customize Colors… and Design › Fonts › Customize Fonts… open Create New + Theme Colors and Create New Theme Fonts on the open master: its twelve theme colours, its + heading and body latin faces; one scheme on that master, not a library of saved schemes), + effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain), backgrounds + (solid, gradient, picture, pattern; a solid fill's transparency; picture transparency, tile, + offset, scale, alignment and mirror; Apply to All copies a picture), slide size presets. File › + New also offers 6 original starter decks (Briefing, Lesson, Pitch, Field Notes, Quarterly, + Evening Talk). - **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop, corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites, @@ -219,7 +226,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc text readable. - **Text:** in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph control; bullets and numbering; levels; autofit; columns; vertical text; date fields in - their own format; hyperlinks drawn in the theme's hyperlink colour and followed in the show. + their own format (Insert › Date and Time picks the format and language, or inserts the sample as + fixed text); hyperlinks drawn in the theme's hyperlink colour and followed in the show. UAX #9 bidi and Arabic shaping with HarfRust, script font slots with per-cluster fallback, `justLow` kashida, bidi caret and selection. - **Editing:** select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; @@ -227,20 +235,25 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Transitions and animations:** 48 transitions including Morph (objects, words, characters, geometry outlines), 64 animation effects (entrance, emphasis, exit, motion paths, media), triggers, by-paragraph builds, Animation Painter, on a shared timeline engine. -- **Slide show:** full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, - presenter window, rehearse timings, custom shows, Set Up Show, Reading View; links and shape +- **Slide show:** full screen, keyboard/mouse navigation, blank screens, go-to-slide (the typed + number is shown), See All Slides, click-to-zoom, pen, laser pointer, presenter window (black, + white, zoom, the slide grid, a timer that pauses and resets, a local clock, and notes size), + rehearse timings, custom shows played on their own, Set Up Show, Reading View; links and shape actions followed on click. -- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master; zoom; - grayscale. -- **Review:** comments with replies and resolve, accessibility checker, a basic spelling list. +- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master (Preserve on + the open master or layout, and Title and Footers placeholders); zoom (dialog: percent or Fit); + Grid Options; New Window; grayscale. +- **Review:** comments with replies and resolve, previous and next across the deck, accessibility + checker, a basic spelling list. - **Media:** MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264, HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV with pure-Rust decoders; poster frames, trim, fades, volume, loop, play across slides. - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel - and MCP. +- **Automation:** 320 engine commands (`cmd!` registrations in `crates/engine/src/cmd/*`), every + one reachable from `deckcraft-cli`, the JSON control channel and MCP. The menu-bar measurement + and the d556ff9 snapshot still count 226. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +289,33 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged | +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset | +| 2026-10-14 | minor | Media remainder: Insert › Icons places Lucide stroke icons. 3-D models and stock content remain. Headline percentages unchanged; the checklist row stays missing | +| 2026-10-14 | trivial | Size and Position dialog. Command count 226 → 227. Breadth stays 79%, ready stays ~55%, UI stays ~60% | +| 2026-10-14 | trivial | 228 commands; Review lists previous and next comment. Breadth stays 79%; the comments checklist row stays D | +| 2026-10-14 | minor | Ecosystem evidence: six original starter decks. The dimension stays ~25% because PowerPoint's large gallery is still absent | +| 2026-10-14 | trivial | Working today: 227 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged | +| 2026-10-14 | trivial | Working today: 228 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged | +| 2026-10-14 | minor | What works today: Create New Theme Colors. The d556ff9 snapshot (226 commands, 27 dialogs) and the breadth / ready percentages are unchanged | +| 2026-10-14 | minor | What works today: Create New Theme Fonts. The d556ff9 snapshot (226 commands, 27 dialogs) and the breadth / ready percentages are unchanged | +| 2026-10-14 | trivial | Insert › Date and Time noted under what works today. The d556ff9 snapshot (226 commands, 27 dialogs) and the readiness percentages are unchanged | +| 2026-10-14 | trivial | Zoom dialog takes a percent or Fit. Breadth stays 79%; the zoom checklist row stays D | +| 2026-10-14 | trivial | Slide show evidence includes a visible go-to-slide number. Breadth stays 79% | +| 2026-10-14 | trivial | Slide show evidence includes custom show playback. Breadth stays 79% | +| 2026-10-14 | trivial | Slide show evidence includes presenter timer pause/reset, a local clock and notes size. Breadth stays 79% | +| 2026-10-14 | trivial | Slide show evidence includes See All Slides and zoom. Breadth stays 79% | +| 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen | +| 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 | +| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged | +| 2026-10-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage unchanged | +| 2026-10-14 | minor | Format Background picture and solid options noted under what works today. Percentages, the 226-command count and the d556ff9 dialog snapshot stay as they are | +| 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% | +| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged | +| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged | +| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages unchanged | +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | +| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..cc7801d7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) - -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -13,18 +11,19 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa | Element | PowerPoint for Mac | DeckCraft | Gap | |---|---|---|---| -| Title bar: AutoSave, Home, Save, Undo/Redo, document title, Share, search | yes | yes, no Share (cloud) | #71 macOS window controls misaligned | +| Title bar: AutoSave, Home, Save, Undo/Redo, document title, Share, search | yes | yes, plus Export; no Share (cloud) | #71 macOS window controls misaligned | | Native macOS menu bar (File, Edit, View, Insert, Format, Arrange, Tools, Slide Show, Window, Help) | yes | **no** (`menus::menu_tree()` is defined but not installed) | 3–5 h | | Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) | | Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder | +| Colour menus: theme and standard colours, hex field, Eyedropper | yes (plus a More Colors dialog) | yes: theme and standard colours, hex field and an Eyedropper that takes the colour drawn under the pointer on the slide (#367) | More Colors dialog; Eyedropper only takes colours from the slide, not the rest of the screen | | Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner | | File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes | | Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | | | Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | | -| Command search (Tell Me) | yes | command palette | #79 default entry fails | +| Command search (Tell Me) | yes | command palette | | | Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h | -| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h | -| Quick Access Toolbar customization | yes | no | P1 partial | +| One window per presentation, New Window, Arrange All | yes | New Window opens one more window on the same presentation (its own slide, live with edits); tabs stay in the first window; no Arrange All | Arrange All | +| Quick Access Toolbar customization | yes | yes: Customize menu (common commands, reset), right-click any ribbon button to add, right-click a toolbar button to remove; saved with the UI state | done | ## File tab (Backstage) @@ -41,8 +40,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Page | PowerPoint | DeckCraft | |---|---|---| -| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me | -| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes and the sample deck (`file.new {theme}`). No online templates | +| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, the first starter decks with More templates →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me | +| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes, six original starter decks (`file.new {template}`) and the sample deck (`file.new {theme}`). No online templates | | Open | Recent, Shared with Me, OneDrive, This PC, Add a Place, Browse; Presentations / Folders; Recover Unsaved Presentations | Recent and Browse (`app.openDialog`), recent presentations with a search box (`file.open`), Recover Unsaved Presentations (`file.recovery.open`). No cloud places or Folders tab | | Info | Name and path, Upload / Share / Copy path / Open file location; Protect, Inspect, Version History, Manage Presentation; Properties, Related Dates, Related People | Name and path, Copy path, Open file location; Inspect Presentation (Accessibility Checker), Manage Presentation (Revert); Size, Slides, Hidden slides and editable Title, Subject, Tags, Categories, Company, Comments, Author (`file.info`, `file.properties`). No Protect, Version History or dates | | Save | Saves; a presentation never saved goes to Save As | Same (`file.save`) | @@ -58,13 +57,15 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | -| Format Background pane | yes | yes | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | +| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | +| Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | -| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | -| Comments pane (threads) | yes | yes | +| Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab | +| Animation Pane | list + advanced timeline | list, reorder, timing, an advanced timeline with draggable bars, play the current slide on the canvas; no preview from the selected effect | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 41 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Bullets and Numbering, Find and Replace, Replace Fonts, Table, Slide Size, Size and Position, Header and Footer, Date and Time, Hyperlink (address, slide and ScreenTip follow the selection), Action Settings, Set Up Show, Zoom (percent or Fit, applies on OK), Comment, Alt Text (decorative follows the selection), Outline, Rename (section, shape, layout), About, Preferences, AutoCorrect, Grid Options, Export, Symbol, Photo Album (Edit Photo Album reopens this dialog), Paste Special, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media (start, end and fades follow the selection), SmartArt chooser, Create New Theme Colors, Create New Theme Fonts, Quit, Notes Pages | +| Design ▸ Effects | named theme effect schemes | DeckCraft, Soft, Crisp, Lift, Glow and Plain (`design.effects`); colours and fonts stay | ## Direct manipulation @@ -74,25 +75,30 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | -| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | +| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | -| Edit points on freeforms | yes | **no** | +| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet | +| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | -| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | -| Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | -| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | +| Text caret, selection, double/triple click, drag-select | yes | yes | +| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | +| Drag the frame of a text box being edited to move it | yes | yes | +| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | +| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | +| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles | +| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Drag and drop of files and between thumbnails | yes | yes | -| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | +| Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate, -group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from -start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag +group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font +dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). ### Text editing keys @@ -127,9 +133,12 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). ## Visual fidelity Colours, spacing, ribbon card and tab geometry follow the observations in -`plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not -imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). +`plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code) +and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0) +on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the +physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel +comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon +consistency is open (#73, #56). ## Estimates @@ -137,19 +146,55 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | -| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | -| Grid Options, edit points UI, crop depth | 4–7 | +| Missing dialogs (Print, Insert Object and the rest of PowerPoint's ~60) | 4–8 | +| Window chrome and system appearance (#41, #71) | 3–5 | +| Arrange All (New Window is one extra window) | 2–3 | +| Edit points UI, crop depth | 3–6 | | Multiple windows | 3–5 | | A screenshot comparison pass on the most-used tabs | 2–4 | -| **Total** | **~30–52** | +| **Total** | **~26–46** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) | +| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) | +| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export | +| 2026-10-12 | minor | A right click selects the shape under the pointer before its context menu opens, as in PowerPoint; edited text keeps its selection and gets text items | +| 2026-10-13 | minor | WordArt Transform gallery on Shape Format ▸ Text Effects; `format.textWarp` applies a preset or clears it. Adjust handles remain open | +| 2026-10-14 | minor | Insert › Icons is a searchable gallery of Lucide stroke icons inserted as shapes. Ribbon chrome stays original drawings | +| 2026-10-14 | minor | Size and Position dialog on Shape Format › Size and the shape context menu. Breadth stays 79%; UI ready stays ~60% | +| 2026-10-14 | trivial | Comments pane lists every slide; Review ▸ Previous and Next walk the deck | +| 2026-10-13 | minor | View › New Window: a second window on the same presentation, with its own slide; Arrange All is still missing | +| 2026-10-14 | minor | File › Home and New show six original starter decks; online templates are still absent | +| 2026-10-14 | minor | Slide Sorter draws a bar per section: Add Section, rename in place, collapse, and drag the bar to move the section and its slides | +| 2026-10-14 | trivial | Photo Album dialog (28 dialogs). Breadth stays 79%, UI ~60% | +| 2026-10-14 | trivial | Edit Photo Album reuses the Photo Album dialog (28 dialogs). Breadth stays 79%, UI ~60% | +| 2026-10-14 | minor | Design › Colors › Customize Colors… opens Create New Theme Colors (the open master's twelve theme colours; dialogs 27 → 28) | +| 2026-10-14 | minor | Design › Fonts › Customize Fonts… opens Create New Theme Fonts (heading and body latin on the open master; dialogs 27 → 28) | +| 2026-10-14 | trivial | Alt Text dialog: Mark as decorative follows the selection | +| 2026-10-14 | trivial | Hyperlink dialog: address, email, slide and ScreenTip follow the selection | +| 2026-10-14 | minor | Insert › Date and Time: format list, language, Update automatically. 28 dialogs. Languages are the ones the date field can compute (English, plus numeric dates for en-GB, fr-FR, ja-JP, sv-SE) | +| 2026-10-14 | trivial | Zoom dialog: percent or Fit, applied on OK. View ▸ Zoom opens it; the arrow keeps the presets | +| 2026-10-13 | trivial | Command search no longer offers an Export command that fails without a path (#79) | +| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 | +| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | +| 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip | +| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint | +| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize | +| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 | +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | +| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | +| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | +| 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss | +| 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | +| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | | 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) | +| 2026-10-11 | minor | Tab in text boxes keeps editing (#78) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 36d44622..6b37e557 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -67,18 +67,24 @@ fn glob(pat: &str, s: &str) -> bool { None => pat == s, Some((head, tail)) => { let Some(rest) = s.strip_prefix(head) else { return false }; - (0..=rest.len()).take_while(|&i| i == 0 || !rest[..i].contains('/')).any(|i| rest.is_char_boundary(i) && glob(tail, &rest[i..])) + let ends = rest.char_indices().map(|(i, _)| i).chain(std::iter::once(rest.len())); + ends.take_while(|&i| !rest[..i].contains('/')).any(|i| glob(tail, &rest[i..])) } } } +/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters). +fn parse_ls_files(out: &[u8]) -> Vec { + out.split(|&b| b == 0).filter(|n| !n.is_empty()).map(|n| String::from_utf8_lossy(n).into_owned()).collect() +} + pub fn run(root: &Path) -> Result<(), String> { let out = Command::new("git") .current_dir(root) - .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) .output() .map_err(|e| format!("git ls-files: {e}"))?; - let files: Vec = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect(); + let files: Vec = parse_ls_files(&out.stdout).into_iter().filter(|l| root.join(l).exists()).collect(); let md = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; let miss = missing(&files, &md); if miss.is_empty() { @@ -104,4 +110,19 @@ mod tests { assert!(missing(&["assets/app-icon/hicolor/16x16/apps/x.png".into()], md).is_empty()); assert_eq!(missing(&["assets/app-icon/hicolor/a/b/apps/x.png".into()], md).len(), 1); } + + #[test] + fn glob_handles_multibyte_names() { + assert!(glob("assets/*/x.png", "assets/żółć/x.png")); + assert!(glob("assets/*.png", "assets/日本語.png")); + assert!(!glob("assets/*/x.png", "assets/ż/ó/x.png")); + assert!(missing(&["assets/żółć/x.png".into()], "`assets/*/x.png`").is_empty()); + assert_eq!(missing(&["assets/é.png".into()], "`assets/*.svg`"), vec!["assets/é.png".to_string()]); + } + + #[test] + fn ls_files_output_is_nul_separated_and_unquoted() { + let names = parse_ls_files("assets/zażółć.png\0docs/images/a b\nc.png\0".as_bytes()); + assert_eq!(names, vec!["assets/zażółć.png".to_string(), "docs/images/a b\nc.png".into()]); + } } diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs new file mode 100644 index 00000000..0ee64bd5 --- /dev/null +++ b/xtask/src/bench.rs @@ -0,0 +1,75 @@ +//! `cargo xtask bench`: the performance baseline. Builds `deckcraft-cli` in release, runs its +//! `bench` on the built-in sample (and any decks given), and compares each step with the saved +//! baseline (`docs/benchmarks/baseline.json`) when there is one; `--save` writes a new baseline. + +use std::path::Path; +use std::process::Command; + +use serde_json::{Value, json}; + +/// A step this many times slower than the baseline is flagged. +const SLOWER: f64 = 1.5; + +pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { + let save = args.contains(&"--save"); + let decks: Vec<&str> = args.iter().copied().filter(|a| !a.starts_with("--")).collect(); + let mut build = super::cargo(); + build.args(["build", "--release", "-q", "-p", "deckcraft-cli"]); + super::run(build, "cargo build --release -p deckcraft-cli")?; + let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root.join("target"), Into::into); + let cli = target.join("release").join(if cfg!(windows) { "deckcraft-cli.exe" } else { "deckcraft-cli" }); + let mut results = vec![]; + let inputs: Vec> = std::iter::once(None).chain(decks.iter().map(|d| Some(*d))).collect(); + for input in inputs { + let mut c = Command::new(&cli); + c.arg("bench").arg("--json"); + match input { + Some(d) => c.arg(d), + None => c.arg("--sample"), + }; + let out = c.output().map_err(|e| format!("{}: {e}", cli.display()))?; + if !out.status.success() { + return Err(format!("bench {}: {}", input.unwrap_or("--sample"), String::from_utf8_lossy(&out.stderr).trim())); + } + let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("bench output: {e}"))?; + results.push(v); + } + let baseline_path = root.join("docs/benchmarks/baseline.json"); + let baseline: Option = std::fs::read(&baseline_path).ok().and_then(|b| serde_json::from_slice(&b).ok()); + let mut slower = 0; + for r in &results { + let file = r["file"].as_str().unwrap_or("?"); + println!("\n{file}: {} slides", r["slides"]); + let base = baseline.as_ref().and_then(|b| b["results"].as_array()?.iter().find(|x| x["file"] == r["file"]).cloned()); + for step in r["steps"].as_array().into_iter().flatten() { + let name = step["step"].as_str().unwrap_or("?"); + let ms = step["medianMs"].as_f64().unwrap_or(0.0); + let was = base.as_ref().and_then(|b| b["steps"].as_array()?.iter().find(|s| s["step"] == step["step"])?["medianMs"].as_f64()); + let cmp = match was { + Some(w) if w > 0.5 => { + let k = ms / w; + if k > SLOWER { + slower += 1; + } + format!(" {k:>5.2}× baseline{}", if k > SLOWER { " SLOWER" } else { "" }) + } + _ => String::new(), + }; + println!(" {name:<24} {ms:>9.1} ms{cmp}"); + } + if let Some(p) = r["peakRssMiB"].as_f64() { + println!(" peak memory {p:>9.1} MiB"); + } + } + if save { + if let Some(dir) = baseline_path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let v = json!({"note": "cargo xtask bench --save; times depend on the machine, compare on the same one", "results": results}); + std::fs::write(&baseline_path, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + println!("\nwrote {}", baseline_path.display()); + } else if baseline.is_none() { + println!("\nno baseline yet: `cargo xtask bench --save` writes docs/benchmarks/baseline.json"); + } + if slower > 0 { Err(format!("{slower} step(s) more than {SLOWER}× slower than the baseline")) } else { Ok(()) } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ad869af2..edb1331e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,6 +4,7 @@ //! invoked through `std::process::Command`. mod assets; +mod bench; mod ico; mod layers; mod parity; @@ -24,6 +25,9 @@ commands: wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + bench [--save] [DECK...] + time open, render, save and export of the sample (and DECKs) in release, and + compare with docs/benchmarks/baseline.json (--save writes it) parity recompute the PowerPoint feature-parity summary in docs/parity-checklist.md ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) @@ -40,6 +44,7 @@ fn main() -> ExitCode { Some("ci") => cmd_ci(), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root()), + Some("bench") => bench::run(&root(), &rest), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("-h" | "--help" | "help") | None => { diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 565b785e..6680f240 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -20,7 +20,43 @@ pub fn run(root: &Path) -> Result<(), String> { } let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; validate(&metadata)?; - println!("PDF writer: pinned craft-pdf/Krilla; write only; parser assertions are development-only"); + // Metadata merges target features even with --filter-platform. Check Cargo's + // selected feature graph separately so native compression never enables threads on WASM. + let output = + Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?; + if !output.status.success() { + return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr))); + } + let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?; + let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?; + for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args([ + "tree", + "--locked", + "--target", + target, + "--edges", + "normal,build", + "--package", + "deckcraft-pdf", + "--depth", + "2", + "--no-dedupe", + "--prefix", + "none", + "--format", + "{p}|{f}", + ]) + .output() + .map_err(|e| format!("cargo tree ({target}): {e}"))?; + if !output.status.success() { + return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr))); + } + validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?; + } + println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only"); Ok(()) } @@ -37,7 +73,7 @@ fn validate(metadata: &Value) -> Result<(), String> { let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); for (name, version, expected_source, expected_features) in [ ("craft-pdf", "0.1.0", source.as_str(), vec!["write"]), - ("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "simple-text"]), + ("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]), ("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]), ] { let package = one_package(name)?; @@ -82,6 +118,25 @@ fn validate(metadata: &Value) -> Result<(), String> { Ok(()) } +fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> { + let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]); + if parallel { + expected.insert("rayon"); + } + let mut found = false; + for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) { + found = true; + let actual: BTreeSet<_> = features.split(',').collect(); + if actual != expected { + return Err(format!("{package}: expected features {expected:?}; got {actual:?}")); + } + } + if !found { + return Err("missing Krilla in the exporter's target dependency graph".into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -99,12 +154,23 @@ mod tests { "resolve":{"nodes":[ {"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]}, {"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]}, - {"id":"backend", "features":["default","raster-images","simple-text"], "deps":[]}, + {"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]}, {"id":"parser", "features":["std"], "deps":[]} ]} }) } + #[test] + fn parallel_compression_is_required_only_on_native_targets() { + let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n"; + let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n"; + assert!(validate_target_features(serial, false).is_ok()); + assert!(validate_target_features(parallel, true).is_ok()); + assert!(validate_target_features(serial, true).is_err()); + assert!(validate_target_features(parallel, false).is_err()); + assert!(validate_target_features("", false).is_err()); + } + #[test] fn rejects_unpinned_or_duplicate_writer_sources() { assert!(validate(&metadata()).is_ok()); @@ -124,7 +190,7 @@ mod tests { for (node, features) in [ (1, json!([])), (1, json!(["write", "write-pdf"])), - (2, json!(["default", "raster-images", "simple-text", "pdf"])), + (2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])), (2, json!(["raster-images"])), (3, json!(["std", "images"])), ] {