diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d061bce5..09b477f38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} diff --git a/AGENTS.md b/AGENTS.md index c998e8715..1cacd993b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,6 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft (`../`). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out -next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a -session, follow its standards, and contribute reusable learnings back there. Never code: repos -don't share code. - These rules bind every AI agent and every human contributor. They come before any task, instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this file wins.** @@ -69,7 +64,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed or `docs/brand/` has no row. - **`NOTICE`** keeps the notices the licences require. - Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this - repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and + repo. DeckCraft embeds its `fonts/latin-manifest.txt` and `fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at `../craft-fonts`). @@ -99,7 +94,6 @@ once and tell the user. ## 3. Never crash People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.** -Standard: craftrules `standards/never-crash.md`. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. @@ -160,7 +154,7 @@ to `main`. `plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`, `STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress -docs follow craftrules `standards/progress-docs.md`: +docs: | File | Holds | |---|---| @@ -184,14 +178,11 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ff680bda2..8382a760b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings. | `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) | | `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About | | `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds | -| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks | +| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks | | `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn | | `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/CLAUDE.md b/CLAUDE.md index 669965b59..979388d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,7 @@ before modules or other crate items; crate-level `//!` documentation may precede ## Start every session 1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`. -2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task. -3. Run `cargo xtask ci` before you change anything, so you know the tree is green. +2. Run `cargo xtask ci` before you change anything, so you know the tree is green. ## Habits - Every user-visible action is a command with tests (`crates/engine/src/cmd/*`). diff --git a/Cargo.lock b/Cargo.lock index a6ef452e9..408e99948 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -823,12 +823,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -851,7 +851,7 @@ dependencies = [ [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "thiserror 2.0.21", ] @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "krilla", "thiserror 2.0.21", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.12.0" +version = "0.14.1" dependencies = [ "arboard", "chrono", @@ -1006,7 +1006,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1024,14 +1024,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.12.0" +version = "0.14.1" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -1041,14 +1041,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.12.0" +version = "0.14.1" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.12.0" +version = "0.14.1" dependencies = [ "chrono", "deckcraft-anim", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.12.0" +version = "0.14.1" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1086,7 +1086,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-model", "serde", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.12.0" +version = "0.14.1" dependencies = [ "kurbo", "serde", @@ -1105,7 +1105,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1123,21 +1123,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.12.0" +version = "0.14.1" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.12.0" +version = "0.14.1" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1165,7 +1165,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.12.0" +version = "0.14.1" dependencies = [ "image", "kurbo", @@ -1174,7 +1174,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1186,15 +1186,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.12.0" +version = "0.14.1" [[package]] name = "deckcraft-opus" -version = "0.12.0" +version = "0.14.1" [[package]] name = "deckcraft-pdf" -version = "0.12.0" +version = "0.14.1" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1247,7 +1247,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.12.0" +version = "0.14.1" dependencies = [ "chrono", "deckcraft-color", @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.12.0" +version = "0.14.1" dependencies = [ "craft-ui", "craft-update", @@ -1286,7 +1286,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.12.0" +version = "0.14.1" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -2708,7 +2708,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "base64 0.22.1", "bumpalo", @@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.14.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 3eada7087..7cd456fdf 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.12.0" +version = "0.14.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,11 +59,11 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } -craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } +craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" } i_overlay = "9" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -109,7 +109,7 @@ codegen-units = 16 debug = 0 [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/README.md b/README.md index 0cdfc5842..730165a8e 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 5a31b3bdf..ecf27f318 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,8 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: animation Effect Options show readable labels; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -147,7 +148,9 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-10-30 | Animation ▸ Effect Options shows readable labels for directions, split/shape variants, wheel spokes and other option values; stored codes are unchanged | +| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | +| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | +| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | | 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) | | 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | @@ -305,7 +308,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 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-10 | Progress docs restructured; 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 | | 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft | @@ -316,7 +319,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| -| 2026-10-30 | trivial | Progress log: animation Effect Options show readable labels. Scores and milestone names unchanged | +| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | +| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged | @@ -416,5 +420,5 @@ app's JSON control channel (`deckcraft --control PORT`). | 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 | -| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md | +| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md | | 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity | diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 7014421d8..541985e5b 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] } diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 6d2b7e943..2ce622e9a 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -18,6 +18,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_arch = "wasm32")] +mod presenter; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/deckcraft-web/src/presenter.rs b/apps/deckcraft-web/src/presenter.rs new file mode 100644 index 000000000..1ec9a298c --- /dev/null +++ b/apps/deckcraft-web/src/presenter.rs @@ -0,0 +1,303 @@ +//! Same-origin popup transport. Each window starts its own eframe/WASM runner. +use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View}; +use std::{ + cell::RefCell, + collections::VecDeque, + rc::{Rc, Weak}, +}; +use wasm_bindgen::{JsCast, closure::Closure}; + +const MAX_MESSAGE: usize = 16 * 1024 * 1024; +const MAX_COMMANDS: usize = 64; +const PREFIX: &str = "#deckcraft-presenter="; +fn error(e: wasm_bindgen::JsValue) -> String { + format!("{e:?}") +} +fn origin() -> Result { + web_sys::window().ok_or("No browser window")?.location().origin().map_err(error) +} +pub fn token() -> Option { + let hash = web_sys::window()?.location().hash().ok()?; + let token = hash.strip_prefix(PREFIX)?; + (!token.is_empty() && token.len() < 128).then(|| token.to_owned()) +} +fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool { + event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref())) +} +fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> { + let text = serde_json::to_string(&value).map_err(|e| e.to_string())?; + if text.len() > MAX_MESSAGE { + return Err("Presenter message is too large".into()); + } + window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error) +} +struct State { + popup: Option, + token: String, + commands: VecDeque, + ready: bool, + refresh_images: bool, +} +pub struct BrowserHost { + state: Rc>, + origin: String, + listener: Closure, + button: Option<(web_sys::HtmlElement, Closure)>, + images: DeltaCache, +} +impl BrowserHost { + pub fn new(ctx: egui::Context) -> Result { + let window = web_sys::window().ok_or("No browser window")?; + let origin = origin()?; + let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true })); + let weak = Rc::downgrade(&state); + let expected = origin.clone(); + let listener = Closure::new(move |event: web_sys::MessageEvent| { + let Some(state) = weak.upgrade() else { return }; + let Ok(mut state) = state.try_borrow_mut() else { return }; + let Some(popup) = state.popup.as_ref() else { return }; + if !same_source(&event, popup, &expected) { + return; + } + let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return }; + let Ok(value) = serde_json::from_str::(&text) else { return }; + if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) { + return; + } + match value.get("kind").and_then(|v| v.as_str()) { + Some("ready") => { + state.ready = true; + state.refresh_images = true; + } + Some("command") if state.commands.len() < MAX_COMMANDS => { + if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) { + state.commands.push_back(command); + } + } + _ => {} + } + ctx.request_repaint(); + }); + window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?; + Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() }) + } + fn remove_button(&mut self) { + if let Some((button, callback)) = self.button.take() { + let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref()); + button.remove(); + } + } + fn recovery_button(&mut self) { + if self.button.is_some() { + return; + } + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(body) = document.body() else { return }; + let Ok(element) = document.create_element("button") else { return }; + let Ok(button) = element.dyn_into::() else { return }; + button.set_text_content(Some("Open presenter window")); + let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer"); + let weak: Weak> = Rc::downgrade(&self.state); + let button_copy = button.clone(); + let callback = Closure::new(move |_event: web_sys::Event| { + if let Some(state) = weak.upgrade() { + match open_popup(&state) { + Ok(()) => { + button_copy.set_text_content(Some("Opening presenter window…")); + } + Err(_) => { + button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry.")); + } + } + } + }); + if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() { + self.button = Some((button, callback)); + } else { + let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref()); + button.remove(); + } + } +} +fn open_popup(state: &Rc>) -> Result<(), String> { + let window = web_sys::window().ok_or("No browser window")?; + let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?; + if let Some(popup) = &state.popup + && !popup.closed().unwrap_or(true) + { + let _ = popup.focus(); + return Ok(()); + } + let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random()); + let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?; + url.set_hash(&format!("{PREFIX}{token}")); + let popup = window + .open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700") + .map_err(error)? + .ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?; + if popup.closed().unwrap_or(true) { + return Err("Browser isolation prevented connecting the presenter window".into()); + } + state.popup = Some(popup); + state.token = token; + state.commands.clear(); + state.ready = false; + Ok(()) +} +impl Host for BrowserHost { + fn open(&mut self) -> Result<(), String> { + self.close(); + let result = open_popup(&self.state); + if result.is_err() { + self.recovery_button(); + } + result + } + fn is_open(&self) -> bool { + self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true)) + } + fn commands(&mut self) -> Vec { + self.state.borrow_mut().commands.drain(..).collect() + } + fn update(&mut self, snapshot: Snapshot) { + if !self.is_open() { + self.recovery_button(); + return; + } + self.remove_button(); + let mut state = self.state.borrow_mut(); + if !state.ready { + return; + } + if let Some(popup) = state.popup.clone() { + let token = state.token.clone(); + let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| { + send( + &popup, + &self.origin, + serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta, + "currentChanged":current_changed, "nextChanged":next_changed}), + ) + }); + if sent.is_ok() { + state.refresh_images = false; + } + } + } + fn close(&mut self) { + self.remove_button(); + let mut state = self.state.borrow_mut(); + if let Some(window) = state.popup.take() { + let _ = window.close(); + } + state.commands.clear(); + state.ready = false; + state.token.clear(); + } +} +impl Drop for BrowserHost { + fn drop(&mut self) { + self.close(); + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref()); + } + } +} + +pub struct Popup { + token: String, + origin: String, + opener: web_sys::Window, + snapshot: Rc>>, + last_received: Rc>, + listener: Closure, + view: View, +} +impl Popup { + pub fn new(token: String, ctx: egui::Context) -> Result { + let window = web_sys::window().ok_or("No browser window")?; + // `opener` is a WindowProxy from another realm; instanceof Window can fail across + // realms. The browser API supplies a WindowProxy or null, and messages still verify + // exact source identity and origin before any data is accepted. + let opener = window.opener().map_err(error)?; + if opener.is_null() || opener.is_undefined() { + return Err("The audience window is unavailable".into()); + } + let opener: web_sys::Window = opener.unchecked_into(); + let origin = origin()?; + let snapshot: Rc>> = Rc::new(RefCell::new(None)); + let last_received = Rc::new(RefCell::new(js_sys::Date::now())); + let received = last_received.clone(); + let target = snapshot.clone(); + let expected_origin = origin.clone(); + let expected_opener = opener.clone(); + let expected_token = token.clone(); + let listener = Closure::new(move |event: web_sys::MessageEvent| { + if !same_source(&event, &expected_opener, &expected_origin) { + return; + } + let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return }; + let Ok(value) = serde_json::from_str::(&text) else { return }; + if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str()) + || value.get("kind").and_then(|v| v.as_str()) != Some("snapshot") + { + return; + } + if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(previous) = target.borrow().as_ref() { + if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) { + data.current = previous.current.clone(); + } + if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) { + data.next = previous.next.clone(); + } + } + *target.borrow_mut() = Some(data); + *received.borrow_mut() = js_sys::Date::now(); + ctx.request_repaint(); + } + }); + window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?; + if let Some(document) = window.document() { + document.set_title("Presenter View — DeckCraft"); + } + let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() }; + send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?; + Ok(popup) + } +} +impl eframe::App for Popup { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let snapshot = self.snapshot.borrow(); + let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str()); + if !self.view.prepare(ui.ctx(), notes) { + return; + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + if self.opener.closed().unwrap_or(true) { + ui.heading("The audience window has closed."); + return; + } + if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 { + ui.heading("The audience window is not responding."); + ui.label("Return to the audience window to resume the connection."); + return; + } + egui::CentralPanel::default().show(ui, |ui| { + if let Some(snapshot) = snapshot.as_ref() { + for command in self.view.ui(ui, snapshot) { + let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command})); + } + } else { + ui.label("Waiting for the audience window…"); + } + }); + } +} +impl Drop for Popup { + fn drop(&mut self) { + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref()); + } + } +} diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f8e68a35c..a385d44c8 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -39,6 +39,10 @@ pub fn start() { log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend); } deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); + if let Some(token) = crate::presenter::token() { + let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?; + return Ok(Box::new(presenter) as Box); + } let inbox: Inbox = Inbox::default(); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); let q = query(); @@ -80,6 +84,11 @@ impl eframe::App for WebShell { /// Delegate one eframe logic pass to the wrapped application. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.app.logic(ctx); + if self.app.show.is_none() + && let Some(host) = self.app.services.presenter.as_mut() + { + host.close(); + } } /// Consume browser drops with fresh input, capture the identity before the asynchronous read, @@ -118,7 +127,15 @@ impl eframe::App for WebShell { /// back to `open_async` (browser picker → inbox) and `download`. fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); + let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) { + Ok(host) => Some(Box::new(host) as Box), + Err(error) => { + log::error!("Presenter window: {error}"); + None + } + }; Services { + presenter, open_async: Some(Box::new(move |purpose: &str, request: u64| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 03859dc0a..ad3f820fa 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -60,6 +60,8 @@ impl eframe::App for App { // 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")] + self.0.cleanup_print_previews(); + #[cfg(target_os = "macos")] std::process::exit(0); } } diff --git a/contributors/people.toml b/contributors/people.toml index caf4daa46..87eac2bad 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -71,3 +68,8 @@ real_name = "Sofia Alves Figueredo" [people.dylanmtaylor] sync_github_name = true + +[people.marshiyar] +display_name = "Arshiya" +real_name = "Arshiya Rahgozar" +sync_github_name = false diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index b5d00bba1..5e3265df6 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Animation Styles", ["Animations", "Animation"], None, - "{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations", + "{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers", has_selection, set ), @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Add Animation", ["Animations", "Advanced Animation"], None, - "{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}", + "{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}", has_selection, add ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Effect Options", ["Animations", "Animation"], None, - "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}", + "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}", has_slide, options ), @@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option { cands.into_iter().find(|c| allowed.contains(&c.as_str())) } -fn build(p: &Value) -> Result { +fn build(p: &Value, slide: (f64, f64)) -> Result { let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; let class = class_of(str_param(p, "class")); let info = match class { @@ -155,7 +155,9 @@ fn build(p: &Value) -> Result { a.start = start_of(st); } if a.class == AnimClass::Path { - a.path = Some(default_path(&a.effect, &a.option)); + a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option))); + } else if p.get("path").is_some() { + return Err(bad("animation", "`path` only fits a motion path (class `path`)")); } if a.effect == "spin" { a.amount = Some(360.0); @@ -166,6 +168,97 @@ fn build(p: &Value) -> Result { Ok(a) } +/// Most points of a motion path given as an array, and longest path string given as text. +const MAX_PATH_POINTS: usize = 1_000; +const MAX_PATH_TEXT: usize = 16 * 1024; +/// Largest offset of a point, in slide points; anything beyond is not a place on any slide. +const MAX_PATH_OFFSET: f64 = 1.0e6; + +/// A number of a path string: slide fractions, rounded to 6 places. +fn path_num(v: f64) -> String { + let t = format!("{:.6}", v); + let t = t.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" || t.is_empty() { "0".into() } else { t.into() } +} + +/// Does a raw motion path string draw: only commands and numbers, every number finite, and at +/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.) +fn raw_path_draws(text: &str) -> bool { + let cs: Vec = text.chars().collect(); + let (mut i, mut nums) = (0, 0usize); + while let Some(&c) = cs.get(i) { + if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) { + let start = i; + i += 1; + while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') { + i += 1; + } + if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') { + let n1 = cs.get(i + 1).copied().unwrap_or(' '); + let n2 = cs.get(i + 2).copied().unwrap_or(' '); + if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) { + i += 2; + while cs.get(i).is_some_and(char::is_ascii_digit) { + i += 1; + } + } + } + let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default(); + match tok.parse::() { + Ok(v) if v.is_finite() => nums += 1, + _ => return false, + } + } else if " ,MmLlCcQqHhVvZzE".contains(c) { + i += 1; + } else { + return false; + } + } + nums >= 4 +} + +/// The motion path `path` asks for, as the OOXML path string animations store: either points +/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?` +/// to return to the first) or that string itself. +fn path_param(p: &Value, slide: (f64, f64)) -> Result> { + let Some(v) = p.get("path") else { return Ok(None) }; + let refuse = |why: &str| bad("animation", format!("`path` {why}")); + match v { + Value::String(text) => { + let ok = text.len() <= MAX_PATH_TEXT + && text.contains(|c: char| c.is_ascii_digit()) + && text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c)) + && raw_path_draws(text); + if !ok { + return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)")); + } + Ok(Some(text.clone())) + } + Value::Array(points) => { + if !(2..=MAX_PATH_POINTS).contains(&points.len()) { + return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points"))); + } + if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) { + return Err(refuse("can't be placed: the slide has no size")); + } + let mut out = String::new(); + for (k, pt) in points.iter().enumerate() { + let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else { + return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points"))); + }; + out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1))); + } + if p.get("closed").and_then(Value::as_bool) == Some(true) { + out.push_str("Z "); + } + out.push('E'); + Ok(Some(out)) + } + _ => Err(refuse("must be [[x, y], …] or an OOXML path string")), + } +} + /// Default motion paths, in slide-fraction units relative to the shape's position. pub fn default_path(effect: &str, option: &str) -> String { let (dx, dy) = match option { @@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String { fn set(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; - let a = build(p)?; + let a = build(p, slide_extent(s)?)?; s.edit(|doc, sel| { let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?; let sl = Arc::make_mut(sl); @@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result { }) } +/// The slide's width and height in points. +fn slide_extent(s: &mut Session) -> Result<(f64, f64)> { + let size = &s.doc()?.doc.slide_size; + Ok((size.width, size.height)) +} + fn add(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; - let a = build(p)?; + let a = build(p, slide_extent(s)?)?; + let path = a.path.clone(); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?); for (k, id) in ids.iter().enumerate() { @@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result { } sl.animations.push(x); } - Ok(json!({"count": sl.animations.len()})) + Ok(match &path { + Some(path) => json!({"count": sl.animations.len(), "path": path}), + None => json!({"count": sl.animations.len()}), + }) }) } @@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result { "byLetter" => TextBuild::ByLetter, _ => TextBuild::AsOne, }); + let path = path_param(p, slide_extent(s)?)?; each_target(s, p, |a| { + if let (Some(path), AnimClass::Path) = (&path, a.class) { + a.path = Some(path.clone()); + } if let Some(o) = &option { // 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 { + if a.class == AnimClass::Path && path.is_none() { a.path = Some(default_path(&a.effect, &o)); } a.option = o; @@ -606,4 +713,76 @@ mod tests { assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1); } } + + fn path_of(s: &mut Session) -> Option { + let all = s.execute("animation.get", &json!({})).unwrap(); + all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from) + } + + fn shape(s: &mut Session) -> u64 { + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap() + } + + /// #1576: points in slide points, as offsets from the shape, become the slide-fraction path. + #[test] + fn a_custom_path_takes_its_points() { + let mut s = Session::with_new(); + let id = shape(&mut s); + let w = s.doc().unwrap().doc.slide_size.width; + let h = s.doc().unwrap().doc.slide_size.height; + let r = s + .execute( + "animation.add", + &json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}), + ) + .unwrap(); + assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E"); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E")); + // closed shapes end back at the start; set replaces; no path keeps the default + s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]})) + .unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E")); + s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E")); + } + + #[test] + fn a_raw_path_string_is_kept_and_options_edits_it() { + let mut s = Session::with_new(); + let id = shape(&mut s); + s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E")); + s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E")); + } + + #[test] + fn a_bad_path_is_refused_and_changes_nothing() { + let mut s = Session::with_new(); + let id = shape(&mut s); + let many: Vec = (0..2_000).map(|i| json!([i, i])).collect(); + for bad_path in [ + json!([[0, 0]]), + json!([]), + json!([[0, 0], [1]]), + json!([[0, 0], ["a", 1]]), + json!([[0, 0], [1e300, 1]]), + json!([[0, 0], [null, 1]]), + json!("M 0 0 L