diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d061bce5a..09b477f38e 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/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f577b3f7b3..1e5b38835f 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,8 +24,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: deckcraft app-dir: DeckCraft diff --git a/AGENTS.md b/AGENTS.md index 8a7ce7cf51..1cacd993b3 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,11 +178,13 @@ 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. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ff680bda22..8382a760b5 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 aae6e13f09..979388d879 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/*`). @@ -19,3 +18,8 @@ before modules or other crate items; crate-level `//!` documentation may precede - Commit after each landed arc (`M.: what`) and push to `origin main`. - Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current. - Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index ed9a49951f..c86df35402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -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,13 +888,14 @@ dependencies = [ [[package]] name = "craft-update" 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 = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -983,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.12.0" +version = "0.15.0" dependencies = [ "arboard", "chrono", @@ -1005,7 +1006,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -1014,7 +1015,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1023,14 +1024,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.12.0" +version = "0.15.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -1040,14 +1041,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.12.0" +version = "0.15.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.12.0" +version = "0.15.0" dependencies = [ "chrono", "deckcraft-anim", @@ -1073,7 +1074,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.12.0" +version = "0.15.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1085,7 +1086,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-model", "serde", @@ -1096,7 +1097,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.12.0" +version = "0.15.0" dependencies = [ "kurbo", "serde", @@ -1104,7 +1105,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1113,7 +1114,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1122,21 +1123,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.12.0" +version = "0.15.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.12.0" +version = "0.15.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1146,7 +1147,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1164,7 +1165,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.12.0" +version = "0.15.0" dependencies = [ "image", "kurbo", @@ -1173,7 +1174,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1185,15 +1186,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.12.0" +version = "0.15.0" [[package]] name = "deckcraft-opus" -version = "0.12.0" +version = "0.15.0" [[package]] name = "deckcraft-pdf" -version = "0.12.0" +version = "0.15.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1211,7 +1212,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1227,7 +1228,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1246,7 +1247,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.12.0" +version = "0.15.0" dependencies = [ "chrono", "deckcraft-color", @@ -1260,7 +1261,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.12.0" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -1285,7 +1286,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1294,7 +1295,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.12.0" +version = "0.15.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -1651,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2707,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", @@ -3013,7 +3014,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3486,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.45.0", ] [[package]] @@ -4041,7 +4042,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4098,7 +4099,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4731,10 +4732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5658,7 +5659,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6176,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.15.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 079604959e..ab2b742915 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.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,10 +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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +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 = "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"] } @@ -108,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 0cdfc58423..730165a8ec 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 b04c4b69f8..c16a262d48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,24 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) + +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; 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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; 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-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -43,7 +58,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md]( `.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready -and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77), +and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74), EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in [docs/roadmap.md](docs/roadmap.md). @@ -116,11 +131,11 @@ ships 26. 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. + image paste on Windows (#1414), editor soak tests. 10–20 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, + and animation bounce end. Still open: a corpus of real decks, #70, #74, 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. @@ -137,7 +152,30 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-30 | Chart titles wrap and clip to their reserved area while leaving a positive minimum plot height; line-count checks tolerate font metrics | +| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered | +| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | +| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | +| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | +| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen | +| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails | +| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | +| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves | +| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-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`) | +| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) | +| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) | +| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide | +| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) | +| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | +| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | | 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) | @@ -148,6 +186,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) | | 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value | | 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) | +| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" | | 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms | | 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All | | 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) | @@ -161,6 +200,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names | +| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it | | 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | | 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | @@ -283,7 +323,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 | @@ -294,7 +334,22 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged | +| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | +| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | +| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | +| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | +| 2026-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 | +| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. | +| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged | +| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged | +| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). 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-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | @@ -312,6 +367,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-26 | trivial | Progress log: spaces after symbol-font characters | | 2026-10-24 | trivial | Progress log: picture and embedded object fills | | 2026-10-22 | trivial | Progress log: table text takes the master's other-text style | | 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas | @@ -384,5 +440,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-cli/src/automation_args.rs b/apps/deckcraft-cli/src/automation_args.rs index 8f0cabf834..a83d1fa06b 100644 --- a/apps/deckcraft-cli/src/automation_args.rs +++ b/apps/deckcraft-cli/src/automation_args.rs @@ -4,29 +4,24 @@ use std::path::PathBuf; pub struct AutomationArgs { - pub read_root: Option, - pub write_root: Option, - read_given: bool, - write_given: bool, + pub read_roots: Vec, + pub write_roots: Vec, } impl AutomationArgs { pub fn new(read_root: Option, write_root: Option) -> Self { - Self { read_root, write_root, read_given: false, write_given: false } + Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() } } - /// 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. + /// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and + /// `--flag=DIR`) may be repeated. 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), + let roots = match flag { + "--automation-read-root" => &mut self.read_roots, + "--automation-write-root" => &mut self.write_roots, _ => return Ok(false), }; - if *given { - return Err(format!("{flag} given twice")); - } let value = match inline { Some(value) => value.to_owned(), None => { @@ -40,8 +35,7 @@ impl AutomationArgs { if value.is_empty() { return Err(format!("{flag} needs a non-empty folder")); } - *root = Some(value.into()); - *given = true; + roots.push(value.into()); Ok(true) } } @@ -51,17 +45,16 @@ mod tests { use super::*; #[test] - fn roots_are_taken_once_and_other_arguments_are_left() { + fn roots_are_repeated_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-read-root=other", &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!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]); + assert_eq!(roots.write_roots, [PathBuf::from("out")]); 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] @@ -71,8 +64,8 @@ mod tests { 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())); + assert_eq!(roots.read_roots, [PathBuf::from("read")]); + assert_eq!(roots.write_roots, [PathBuf::from("write")]); } let mut roots = AutomationArgs::new(None, None); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); @@ -84,7 +77,7 @@ mod tests { 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())); + assert_eq!(roots.read_roots, [PathBuf::from("-folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]); } } diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 4227905f8c..0cf057b3cb 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT. }, Subcommand { name: "run", - usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--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 @@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as 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. +--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting +and command-line length limits. Each line can be up to 16 MiB, including commands with embedded +media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF +line endings and an optional UTF-8 BOM at the start of the file are accepted. +Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear. +File errors name the path; line-reading, JSON and command errors also name the line number. + + deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft + deckcraft-cli run \\ --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ @@ -127,16 +136,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe }, Subcommand { name: "mcp", - usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]", + 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).", +(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either +flag to grant access to more folders; 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", @@ -180,7 +189,10 @@ A presentation is edited by running commands on it: deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft `deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand; -`deckcraft-cli help run` explains how to write a command.", +`deckcraft-cli help run` explains how to write a command. + +If an output reader closes early, run, render, render-transition and convert continue; their exit +status reflects the work, not output delivery. Stop the process to cancel them.", list.join("\n") ) } diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c2c38e10fe..5701b72f82 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -7,23 +7,38 @@ mod automation_args; mod help; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; use deckcraft_engine::Session; use serde_json::{Value, json}; +static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false); + macro_rules! outln { ($($arg:tt)*) => {{ - use std::io::Write as _; - if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) { if e.kind() == std::io::ErrorKind::BrokenPipe { - std::process::exit(0); + STDOUT_CLOSED.store(true, Ordering::Relaxed); + } else { + eprintln!("deckcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); } - eprintln!("deckcraft-cli: can't write to stdout: {e}"); - std::process::exit(1); } }}; } +fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> { + use std::io::Write as _; + #[cfg(unix)] + let mut stdout = { + use std::os::fd::AsFd as _; + std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?) + }; + #[cfg(not(unix))] + let mut stdout = std::io::stdout(); + stdout.write_all(format!("{args}\n").as_bytes()) +} + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let rest = args.get(1..).unwrap_or(&[]); @@ -146,9 +161,13 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6 f()?; ms.push(t.elapsed().as_secs_f64() * 1000.0); } + Ok(summarize_ms(ms)) +} + +/// Summarize one population of measurements; each maximum must come from that population. +fn summarize_ms(mut ms: Vec) -> (f64, f64) { 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))) + (ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0)) } /// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere. @@ -210,9 +229,13 @@ fn bench(args: &[String]) -> Result<(), String> { ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?), ("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), ]; - let render_total: f64 = per_slide.iter().map(|m| m.0).sum(); - let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max); - steps.insert(1, ("render all slides (1×)", (render_total, render_max))); + let render_all = time_ms(runs, || { + for i in 0..slides { + deckcraft_engine::render::render_slide(&doc, i, &opts); + } + Ok(()) + })?; + steps.insert(1, ("render all slides (1×)", render_all)); let peak = peak_rss_mib(); if as_json { let v = json!({ @@ -450,6 +473,69 @@ fn output_format(path: &str) -> Result<&'static str, String> { }) } +enum CommandSource { + Inline(String), + File(String), +} + +const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024; + +fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec) -> std::io::Result { + line.clear(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(!line.is_empty()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let count = newline.unwrap_or(available.len()); + line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit")) + })?; + line.extend_from_slice(&available[..count]); + reader.consume(count + usize::from(newline.is_some())); + if newline.is_some() { + return Ok(true); + } + } +} + +/// The same command syntax and execution path for --cmd and each command-file line. +fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> { + let (id, params) = match command.split_once('=') { + Some((id, j)) => (id, serde_json::from_str::(j).map_err(|e| format!("{id}: bad JSON: {e}"))?), + None => (command, json!({})), + }; + let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?; + if print { + outln!("{id} → {r}"); + } + Ok(()) +} + +fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> { + use std::io::BufReader; + + let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let mut reader = BufReader::new(file); + let mut bytes = Vec::new(); + let mut line_number = 0usize; + loop { + line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?; + if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? { + break; + } + let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?; + let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line }; + let command = line.trim(); + if command.is_empty() || command.starts_with('#') { + continue; + } + run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?; + } + Ok(()) +} + fn run(args: &[String]) -> Result<(), String> { let mut input = None; let mut sample = false; @@ -463,7 +549,8 @@ fn run(args: &[String]) -> Result<(), String> { 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 COMMAND[=PARAMS]")?), + "--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)), + "--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)), "--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" => { @@ -486,14 +573,10 @@ fn run(args: &[String]) -> Result<(), String> { } let mut s = Session::new(); open(&mut s, input.as_deref(), sample)?; - for c in cmds { - let (id, p) = match c.split_once('=') { - Some((id, j)) => (id.to_string(), serde_json::from_str::(j).map_err(|e| format!("{id}: bad JSON: {e}"))?), - None => (c.clone(), json!({})), - }; - let r = s.execute(&id, &p).map_err(|e| e.to_string())?; - if print { - outln!("{id} → {r}"); + for source in cmds { + match source { + CommandSource::Inline(command) => run_command(&mut s, &command, print)?, + CommandSource::File(path) => run_command_file(&mut s, &path, print)?, } } if let Some(p) = save { @@ -624,7 +707,7 @@ fn mcp(args: &[String]) -> Result<(), String> { other => return Err(format!("unknown mcp option `{other}`")), } } - let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?; 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)" @@ -641,8 +724,12 @@ so drop --connect (or use --connect without the roots)" 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())); + let show = |roots: Vec| if roots.is_empty() { "none".to_string() } else { roots.join(", ") }; + eprintln!( + "deckcraft-cli: files confined: read roots {}, write roots {}", + show(roots.read_roots().map(|r| r.display().to_string()).collect()), + show(roots.write_roots().map(|r| r.display().to_string()).collect()) + ); h = h.with_automation_roots(Some(roots)); } Box::new(h) @@ -663,6 +750,16 @@ mod tests { a.iter().map(|s| s.to_string()).collect() } + #[test] + fn timing_summary_keeps_median_and_max_from_one_population() { + let slide_medians = [8.99, 16.565]; + let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]); + assert_eq!(whole_deck, (17.0, 18.0)); + assert!(whole_deck.1 >= whole_deck.0); + assert!(slide_medians.iter().sum::() > whole_deck.1, "fixture must expose summing independent slide medians"); + assert_eq!(summarize_ms(vec![]), (0.0, 0.0)); + } + #[test] fn render_help_prints_usage_and_writes_nothing() { assert!(render(&args(&["--help"])).is_ok()); diff --git a/apps/deckcraft-cli/tests/it/cli.rs b/apps/deckcraft-cli/tests/it/cli.rs index d04e55c76a..a6ec024856 100644 --- a/apps/deckcraft-cli/tests/it/cli.rs +++ b/apps/deckcraft-cli/tests/it/cli.rs @@ -20,6 +20,153 @@ fn words(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } +fn command_file_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn inspected_titles(out: &str) -> Vec { + let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap(); + let doc: serde_json::Value = serde_json::from_str(answer).unwrap(); + doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn command_file_runs_utf8_lines_in_order_and_skips_comments() { + let dir = command_file_dir("lines"); + let file = dir.join("commands with spaces.txt"); + std::fs::write( + &file, + "\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}", + ) + .unwrap(); + let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]); + let out = stdout(&o); + let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect(); + assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]); + let titles = inspected_titles(&out); + assert_eq!(titles.len(), 4); + assert_eq!(titles[1], "Résumé = # 测试"); + assert_eq!(titles[3], "Last"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn command_files_and_inline_commands_keep_argument_order() { + let dir = command_file_dir("mixed"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + let saved = dir.join("result.deckcraft"); + std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap(); + std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap(); + let o = cli(&[ + "run", + "--cmd", + "slide.new={\"title\":\"Inline first\"}", + "--cmd-file", + &first.to_string_lossy(), + "--cmd", + "slide.new={\"title\":\"Inline middle\"}", + "--cmd-file", + &second.to_string_lossy(), + "--cmd", + "document.inspect", + "--print", + "--save", + &saved.to_string_lossy(), + ]); + let out = stdout(&o); + let titles = inspected_titles(&out); + assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]); + assert_eq!(out.lines().count(), 5); + let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]); + assert_eq!(inspected_titles(&stdout(&reopened)), titles); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_failed_file_command_stops_later_commands_and_final_outputs() { + let dir = command_file_dir("failure"); + let file = dir.join("commands.txt"); + let later = dir.join("later-in-file.deckcraft"); + let inline = dir.join("later-inline.deckcraft"); + let save = dir.join("saved.deckcraft"); + let export = dir.join("exported.png"); + let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()})); + for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] { + let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()})); + std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap(); + std::fs::write(&save, b"keep saved file").unwrap(); + std::fs::write(&export, b"keep exported file").unwrap(); + let o = cli(&[ + "run", + "--print", + "--cmd-file", + &file.to_string_lossy(), + "--cmd", + &inline_command, + "--save", + &save.to_string_lossy(), + "--export", + &export.to_string_lossy(), + ]); + let err = stderr(&o); + assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}"); + assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran"); + assert!(!later.exists() && !inline.exists(), "commands after the failure must not run"); + assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file"); + assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn command_file_read_errors_name_the_path_and_utf8_line() { + let dir = command_file_dir("read-errors"); + let missing = dir.join("missing.txt"); + let invalid = dir.join("invalid.txt"); + let save = dir.join("out.deckcraft"); + std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap(); + for file in [&missing, &dir, &invalid] { + let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]); + let err = stderr(&o); + assert!(err.contains(&file.display().to_string()), "{err}"); + if file == &invalid { + assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}"); + } + assert!(!save.exists()); + } + assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file")); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn command_file_rejects_an_oversized_line_before_running_it() { + let dir = command_file_dir("line-limit"); + let file = dir.join("commands.txt"); + let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024)); + std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap(); + + let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]); + let err = stderr(&o); + assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}"); + assert!(o.stdout.is_empty(), "the oversized command must not run"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn inline_commands_keep_their_existing_syntax_and_errors() { + let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]); + let out = stdout(&o); + let titles = inspected_titles(&out); + assert_eq!(titles.len(), 3); + assert_eq!(titles[2], "Inline = #"); + assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:")); + assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`")); +} + #[test] fn an_unknown_subcommand_is_named_before_the_overview() { for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { @@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() { "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", + "--cmd-file FILE reads the same commands from a UTF-8 text file, one per line", + "commands execute in the order those options appear.", + "If a command fails, run stops there and saves nothing.", ] { assert!(out.contains(fact), "{fact}: {out}"); } diff --git a/apps/deckcraft-cli/tests/it/closed_stdout.rs b/apps/deckcraft-cli/tests/it/closed_stdout.rs new file mode 100644 index 0000000000..aeb20dba6c --- /dev/null +++ b/apps/deckcraft-cli/tests/it/closed_stdout.rs @@ -0,0 +1,110 @@ +use std::fs::{self, File}; +use std::os::fd::OwnedFd; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn closed_stdout() -> Stdio { + let (writer, reader) = UnixStream::pair().unwrap(); + drop(reader); + Stdio::from(OwnedFd::from(writer)) +} + +fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap() +} + +#[test] +fn broken_pipe_does_not_skip_later_commands_or_save() { + let dir = Scratch::new("save"); + let out = run( + &dir.0, + &[ + "run", + "--cmd", + r#"slide.new={"layout":"titleOnly","title":"First"}"#, + "--cmd", + r#"slide.new={"layout":"titleOnly","title":"Later"}"#, + "--print", + "--save", + "out.deckcraft", + ], + closed_stdout(), + ); + assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr)); + let saved = dir.0.join("out.deckcraft"); + assert!(saved.is_file(), "requested save was skipped"); + let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap(); + assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr)); + let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap(); + let slides = data["slides"].as_array().unwrap(); + assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}"); +} + +#[test] +fn broken_pipe_does_not_hide_a_later_command_failure() { + let dir = Scratch::new("command-failure"); + let out = run( + &dir.0, + &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"], + closed_stdout(), + ); + assert!(!out.status.success(), "a later command failure returned success"); + assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved"); + assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic"); +} + +#[test] +fn broken_pipe_does_not_hide_a_save_failure() { + let dir = Scratch::new("save-failure"); + fs::create_dir(dir.0.join("out.deckcraft")).unwrap(); + let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout()); + assert!(!out.status.success(), "a failed save returned success"); + assert!(!out.stderr.is_empty(), "missing save failure diagnostic"); +} + +#[test] +fn broken_pipe_does_not_skip_later_rendered_slides() { + let dir = Scratch::new("render-all"); + let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped()); + assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr)); + let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout()); + assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr)); + let names = |folder: &str| { + let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect(); + files.sort(); + files + }; + let expected = names("normal"); + assert!(expected.len() > 1, "sample must have multiple slides"); + assert_eq!(names("closed"), expected, "rendering stopped after stdout closed"); +} + +#[test] +fn other_stdout_write_errors_fail() { + let dir = Scratch::new("write-failure"); + let path = dir.0.join("readonly"); + fs::write(&path, b"").unwrap(); + let readonly = File::open(path).unwrap(); + let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly)); + assert!(!out.status.success(), "a non-broken stdout error returned success"); + assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout")); +} diff --git a/apps/deckcraft-cli/tests/it/main.rs b/apps/deckcraft-cli/tests/it/main.rs index c6cdca9b03..f1e1c0893a 100644 --- a/apps/deckcraft-cli/tests/it/main.rs +++ b/apps/deckcraft-cli/tests/it/main.rs @@ -1,4 +1,6 @@ mod cli; +#[cfg(unix)] +mod closed_stdout; mod convert; mod damaged; // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). diff --git a/apps/deckcraft-cli/tests/it/mcp_roots.rs b/apps/deckcraft-cli/tests/it/mcp_roots.rs index f42782c463..c80f8fe3f3 100644 --- a/apps/deckcraft-cli/tests/it/mcp_roots.rs +++ b/apps/deckcraft-cli/tests/it/mcp_roots.rs @@ -87,6 +87,45 @@ fn mcp_automation_roots_confine_the_server() { let _ = std::fs::remove_dir_all(base); } +#[test] +fn mcp_accepts_multiple_read_and_write_roots() { + let base = tmpdir("multiple-roots"); + let read1 = base.join("read1"); + let read2 = base.join("read2"); + let write1 = base.join("write1"); + let write2 = base.join("write2"); + for dir in [&read1, &read2, &write1, &write2] { + std::fs::create_dir_all(dir).unwrap(); + } + let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt")); + std::fs::write(&input1, "one").unwrap(); + std::fs::write(&input2, "two").unwrap(); + let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft")); + let args = [ + "--automation-read-root".to_owned(), + read1.to_string_lossy().into_owned(), + format!("--automation-read-root={}", read2.display()), + "--automation-write-root".to_owned(), + write1.to_string_lossy().into_owned(), + format!("--automation-write-root={}", write2.display()), + ]; + let args = args.iter().map(String::as_str).collect::>(); + let calls = [ + ("open_presentation", serde_json::json!({"path": input1})), + ("open_presentation", serde_json::json!({"path": input2})), + ("save_presentation", serde_json::json!({"path": output1})), + ("save_presentation", serde_json::json!({"path": output2})), + ]; + let (success, stderr, replies) = mcp_session(&args, &calls); + assert!(success, "{stderr}"); + for reply in &replies { + assert_eq!(reply["result"]["isError"], false, "{reply}"); + } + assert!(output1.exists()); + assert!(output2.exists()); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn mcp_automation_root_flags_are_checked_up_front() { let base = tmpdir("roots-args"); @@ -103,8 +142,12 @@ fn mcp_automation_root_flags_are_checked_up_front() { // 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"); + for flag in ["--automation-read-root", "--automation-write-root"] { + let mut full = vec!["mcp", flag, dir, flag]; + full.push(dir); + let out = run(&full); + assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr)); + } 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"); diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 7014421d8a..541985e5b2 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 6d2b7e9432..2ce622e9ab 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 0000000000..1ec9a298c1 --- /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 f8e68a35c2..a385d44c80 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 605dc84a1b..ad3f820fac 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); } } @@ -187,7 +189,7 @@ fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("DeckCraft update helper failed: {e}"); std::process::exit(1); diff --git a/contributors/people.toml b/contributors/people.toml index 31dccab20b..87eac2bad7 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" @@ -68,3 +65,11 @@ sync_github_name = true [people.SofiaAFigueredo] 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/anim/src/morph.rs b/crates/anim/src/morph.rs index a869ebdb37..be41aeacb8 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -1,5 +1,7 @@ //! Morph: matching shapes between two slides, interpolating their boxes and building the frames. +use std::collections::{HashMap, HashSet, VecDeque}; + use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; @@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String { /// 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). +/// +/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per +/// pass: matching costs the sum of the two shape lists, not their product. 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()]; let mut used_b = vec![false; b.shapes.len()]; - let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [ - &|x, y| x.name.starts_with("!!") && x.name == y.name, - &|x, y| !x.name.is_empty() && x.name == y.name, - &|x, y| { - if x.kind_name() != y.kind_name() { - return false; + // Each shape's text, worked out once rather than inside every comparison. + let text_a: Vec = a.shapes.iter().map(text_of).collect(); + let text_b: Vec = b.shapes.iter().map(text_of).collect(); + let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone())); + let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone())); + let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string())); + let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() { + Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())), + _ => None, + }; + let passes: [&dyn Fn(&Shape, &str) -> Option; 4] = [&forced, &named, &same_text, &same_geom]; + for key in passes { + // Unused shapes of `b` by key, in slide order: the front of each queue is the first unused + // match, which is the one a search through `b` would find. + let mut index: HashMap> = HashMap::new(); + for (j, sb) in b.shapes.iter().enumerate() { + if used_b.get(j).copied().unwrap_or(true) { + continue; } - let tx = text_of(x); - !tx.trim().is_empty() && tx == text_of(y) - }, - &|x, y| { - 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() - }, - ]; - for pass in passes { + if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) { + index.entry(k).or_default().push_back(j); + } + } + if index.is_empty() { + continue; + } for (i, sa) in a.shapes.iter().enumerate() { if used_a.get(i).copied().unwrap_or(true) { continue; } - let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb)); - if let Some((j, sb)) = found { - if let Some(u) = used_a.get_mut(i) { - *u = true; - } - if let Some(u) = used_b.get_mut(j) { - *u = true; - } - pairs.push((sa.id, sb.id)); + let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue }; + let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue }; + let Some(sb) = b.shapes.get(j) else { continue }; + if let Some(u) = used_a.get_mut(i) { + *u = true; + } + if let Some(u) = used_b.get_mut(j) { + *u = true; } + pairs.push((sa.id, sb.id)); } } pairs } +/// What one [`morph_pairs`] pass matches shapes on. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum MorphKey { + Name(String), + Text(&'static str, String), + Geom(&'static str, String), +} + fn lerp(a: f64, b: f64, t: f64) -> f64 { let v = a + (b - a) * t; if v.is_finite() { @@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: f.shapes.push((sh, old)); id }; + // Lookups by id, built once (the first shape or pair with an id wins, as a search would). + let paired_a: HashSet = pairs.iter().map(|(ia, _)| *ia).collect(); + let mut partner: HashMap = HashMap::with_capacity(pairs.len()); + for (ia, ib) in &pairs { + partner.entry(*ib).or_insert(*ia); + } + let mut a_by_id: HashMap = HashMap::with_capacity(a.shapes.len()); + for x in &a.shapes { + a_by_id.entry(x.id).or_insert(x); + } for sa in &a.shapes { - if !pairs.iter().any(|(ia, _)| *ia == sa.id) { + if !paired_a.contains(&sa.id) { push(sa.clone(), true, 1.0 - s); } } for sb in &b.shapes { - let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia)); + let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied()); let Some(sa) = pair else { if !hidden_b(sb.id) { push(sb.clone(), false, s); diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index eb039e3da6..15fb8ce9fb 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -58,6 +58,9 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// The current slide was reached by going back (← / Backspace), so the show plays the + /// transition of the slide it left, in reverse, as PowerPoint does (#1531). + pub backward: 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. @@ -208,6 +211,7 @@ impl ShowState { self.active = true; self.slide = i; self.ended = false; + self.backward = false; // Time spent elsewhere never counts for this slide's triggered animations. self.last_now = None; let steps = ShowState::steps(pres, i); @@ -309,6 +313,7 @@ impl ShowState { } self.step = ShowState::steps(pres, self.slide); self.playing = false; + self.backward = false; return ShowAction::Slide(self.slide); } // #844: undoing the automatic first step left the slide as before it appeared, and the show @@ -337,6 +342,7 @@ impl ShowState { self.step = ShowState::steps(pres, i); self.playing = false; } + self.backward = true; ShowAction::Slide(i) } None => ShowAction::None, diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 043fd7858c..385520b6db 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -763,6 +763,26 @@ fn show_skips_hidden_slides() { assert_eq!(s.goto(&p, 99), ShowAction::None); } +#[test] +fn show_remembers_a_slide_was_reached_by_going_back() { + // #1531: the UI plays the left slide's transition reversed only for Back. + let p = pres(3); + let mut s = ShowState::new(&p); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + assert!(!s.backward); + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + assert!(s.backward); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + assert!(!s.backward); + assert_eq!(s.goto(&p, 0), ShowAction::Slide(0)); + assert!(!s.backward, "a jump is not Back"); + // Back from the end screen shows the last slide again, without a transition to reverse. + assert_eq!(s.goto(&p, 2), ShowAction::Slide(2)); + assert_eq!(s.next(&p), ShowAction::End); + assert_eq!(s.prev(&p), ShowAction::Slide(2)); + assert!(!s.backward); +} + #[test] fn show_loops() { let mut p = pres(3); @@ -1135,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() { assert_eq!(tl.effects().len(), 6_000); assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]); } + +#[test] +fn morph_pairs_take_the_first_unused_match_in_slide_order() { + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")]; + b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")]; + // By name first, in slide order; the shape of `a` left over pairs by kind and geometry with + // the shape of `b` left over. + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]); +} + +#[test] +fn morph_of_large_slides_stays_linear() { + let n = 20_000u32; + let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}"))); + let mut a = Slide::default(); + let mut b = Slide::default(); + // Text no shape on the other slide shares: every pass looks for a partner and finds none. + a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect(); + b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect(); + // One named pair at opposite ends. + a.shapes[0].name = "Logo".into(); + b.shapes[n as usize - 1].name = "Logo".into(); + // No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing + // takes minutes, so a regression shows as a test that hangs rather than a failed assertion. + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]); + let p = deckcraft_model::defaults::new_presentation(None); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + // Every shape fades in place; the named pair differs in text, so it cross-fades as two. + assert_eq!(f.shapes.len(), 2 * n as usize); +} + +#[test] +fn paragraph_states_of_a_large_build_stay_linear() { + let n = 20_000usize; + let mut s = slide_with(vec![]); + let mut body = TextBody::from_text(&vec!["line"; n].join("\n")); + // One first-level paragraph owning every other paragraph. + for p in body.paragraphs.iter_mut().skip(1) { + p.level = 1; + } + s.shapes[0].text = Some(body); + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.text_build = TextBuild::ByParagraph; + s.animations = vec![a]; + let tl = Timeline::new(&s, W, H); + // No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here. + assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n); + for p in 0..n { + assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible); + assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible); + } + assert_eq!(tl.animated_shapes(), [ShapeId(1)]); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index c995f2eaa2..5784f4ea82 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -103,6 +103,41 @@ pub struct Timeline { /// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent: /// (shape, member paragraph, owner paragraph). groups: Vec<(ShapeId, usize, usize)>, + /// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects + /// rather than a scan of every effect, group and colour on the slide. + index: TimelineIndex, +} + +#[derive(Clone, Debug, Default)] +struct TimelineIndex { + /// Effects (indices into `effects`, in order) by target shape and paragraph. + by_target: HashMap<(ShapeId, Option), Vec>, + /// Effects (indices into `effects`, in order) by target shape. + by_shape: HashMap>, + /// Owner paragraph of each member paragraph (the first group recorded wins). + owner: HashMap<(ShapeId, usize), usize>, + /// Groups (member, owner) by shape, in order. + groups_by_shape: HashMap>, + /// Base colour by shape (the first recorded wins). + color: HashMap, +} + +impl TimelineIndex { + fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex { + let mut ix = TimelineIndex::default(); + for (i, e) in effects.iter().enumerate() { + ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i); + ix.by_shape.entry(e.timing.shape).or_default().push(i); + } + for &(shape, member, owner) in groups { + ix.owner.entry((shape, member)).or_insert(owner); + ix.groups_by_shape.entry(shape).or_default().push((member, owner)); + } + for &(shape, c) in colors { + ix.color.entry(shape).or_insert(c); + } + ix + } } enum Status { @@ -324,6 +359,7 @@ impl Timeline { }; tl.colors.push((id, c)); } + tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors); tl } @@ -413,8 +449,12 @@ impl Timeline { /// Paragraph indices of `shape` animated separately (by-paragraph builds). pub fn paragraph_targets(&self, shape: ShapeId) -> Vec { - let mut v: Vec = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect(); - v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::>()); + let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice); + let mut v: Vec = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect(); + let owners: HashSet = v.iter().copied().collect(); + if let Some(groups) = self.index.groups_by_shape.get(&shape) { + v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member)); + } v.sort_unstable(); v.dedup(); v @@ -422,13 +462,8 @@ impl Timeline { /// Shapes with any effect. pub fn animated_shapes(&self) -> Vec { - let mut v: Vec = Vec::new(); - for e in &self.effects { - if !v.contains(&e.timing.shape) { - v.push(e.timing.shape); - } - } - v + let mut seen: HashSet = HashSet::new(); + self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect() } /// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been @@ -456,9 +491,9 @@ impl Timeline { 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)); + let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p)); + let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i)); + let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let mut st = AnimState::default(); // Initial visibility comes from the first effect in the Animation Pane order. let mut ordered: Vec<&Effect> = mine.collect(); diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index 43abce22bc..d463493a1e 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -425,6 +425,8 @@ impl Decoder { fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> { let mut tu = 0usize; + // Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU. + let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count(); let mut pos = 0; while pos < data.len() { let obu = parse_obu_unit(data, pos)?; @@ -444,9 +446,10 @@ impl Decoder { } OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => { if let Some(o) = self.frame_obu(&obu)? { - if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 { + if existing >= 32 { return Err(Error::Invalid("too many frame outputs in chunk")); } + existing += usize::from(matches!(o, Output::Existing { .. })); out.push((o, tu, spatial_id)); } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 59513c6b0a..6f20c41434 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -515,8 +515,18 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c } } -pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet { - current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default() +/// Every name on the slide, group members included, and the number of shapes (which exceeds the +/// number of names when two shapes share one). +pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet, usize) { + let mut names = std::collections::HashSet::new(); + let mut count = 0; + if let Some(v) = current_shapes(doc, sel) { + deckcraft_model::walk(v, &mut |sh, _| { + names.insert(sh.name.clone()); + count += 1; + }); + } + (names, count) } /// The command palette runs a command with `{}`. True when the params doc names a value that diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index ac8348f205..5e3265df6c 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; @@ -339,7 +446,10 @@ fn options(s: &mut Session, p: &Value) -> Result { } fn clear(s: &mut Session, p: &Value) -> Result { - let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide); + let i = match opt_usize_param(p, "index", "animation.clear")? { + Some(i) => i, + None => s.doc()?.selection.slide, + }; s.edit(|doc, _| { if let Some(sl) = doc.slides.get_mut(i) { Arc::make_mut(sl).animations.clear(); @@ -434,6 +544,19 @@ mod tests { use super::*; + #[test] + fn clear_rejects_invalid_index() { + let mut s = Session::with_new(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap(); + let id = r.get("id").unwrap().as_u64().unwrap(); + s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap(); + for bad_index in [json!(-1), json!(1.5)] { + assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err()); + let left = s.execute("animation.get", &json!({})).unwrap(); + assert_eq!(left.as_array().unwrap().len(), 1); + } + } + #[test] fn animation_painter_copies_effects_between_shapes() { let mut s = Session::with_new(); @@ -590,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